UNPKG

@nx/angular

Version:

The Nx Plugin for Angular contains executors, generators, and utilities for managing Angular applications and libraries within an Nx workspace. It provides: - Integration with libraries such as Storybook, Jest, ESLint, Tailwind CSS, Playwright and Cypre

322 lines (321 loc) • 15.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.nxComponentTestingPreset = nxComponentTestingPreset; const cypress_preset_1 = require("@nx/cypress/plugins/cypress-preset"); const ct_helpers_1 = require("@nx/cypress/src/utils/ct-helpers"); const devkit_1 = require("@nx/devkit"); const ts_solution_setup_1 = require("@nx/js/src/utils/typescript/ts-solution-setup"); const fs_1 = require("fs"); const path_1 = require("path"); const semver_1 = require("semver"); /** * Angular nx preset for Cypress Component Testing * * This preset contains the base configuration * for your component tests that nx recommends. * including a devServer that supports nx workspaces. * you can easily extend this within your cypress config via spreading the preset * @example * export default defineConfig({ * component: { * ...nxComponentTestingPreset(__filename) * // add your own config here * } * }) * * @param pathToConfig will be used for loading project options and to construct the output paths for videos and screenshots * @param options override options */ function nxComponentTestingPreset(pathToConfig, options) { if (global.NX_GRAPH_CREATION) { // this is only used by plugins, so we don't need the component testing // options, cast to any to avoid type errors return (0, cypress_preset_1.nxBaseCypressPreset)(pathToConfig, { testingType: 'component', }); } let graph; try { graph = (0, devkit_1.readCachedProjectGraph)(); } catch (e) { throw new Error( // don't want to strip indents so error stack has correct indentation `Unable to read the project graph for component testing. This is likely due to not running via nx. i.e. 'nx component-test my-project'. Please open an issue if this error persists. ${e.stack ? e.stack : e}`); } const ctProjectConfig = (0, ct_helpers_1.getProjectConfigByPath)(graph, pathToConfig); const ctConfigurationName = process.env.NX_CYPRESS_TARGET_CONFIGURATION; const ctContext = (0, ct_helpers_1.createExecutorContext)(graph, ctProjectConfig.targets, ctProjectConfig.name, options?.ctTargetName || 'component-test', ctConfigurationName); const buildTarget = options?.buildTarget ? (0, devkit_1.parseTargetString)(options.buildTarget, graph) : // for backwards compat, if no buildTargetin the preset options, get it from the target options getBuildableTarget(ctContext); if (!buildTarget.project && !graph.nodes?.[buildTarget.project]?.data) { throw new Error((0, devkit_1.stripIndents) `Unable to find project configuration for build target. Project Name? ${buildTarget.project} Has project config? ${!!graph.nodes?.[buildTarget.project]?.data}`); } const fromWorkspaceRoot = (0, path_1.relative)(ctContext.root, pathToConfig); const normalizedFromWorkspaceRootPath = (0, fs_1.lstatSync)(pathToConfig).isFile() ? (0, path_1.dirname)(fromWorkspaceRoot) : fromWorkspaceRoot; const offset = isOffsetNeeded(ctContext, ctProjectConfig) ? (0, devkit_1.offsetFromRoot)(normalizedFromWorkspaceRootPath) : undefined; const buildContext = (0, ct_helpers_1.createExecutorContext)(graph, graph.nodes[buildTarget.project]?.data.targets, buildTarget.project, buildTarget.target, buildTarget.configuration); const buildableProjectConfig = normalizeBuildTargetOptions(buildContext, ctContext, offset); return { ...(0, cypress_preset_1.nxBaseCypressPreset)(pathToConfig, { testingType: 'component' }), // NOTE: cannot use a glob pattern since it will break cypress generated tsconfig. specPattern: ['src/**/*.cy.ts', 'src/**/*.cy.js'], // Cy v12.17.0+ does not work with aboslute paths for index file // but does with relative pathing, since relative path is the default location, we can omit it indexHtmlFile: requiresAbsolutePath() ? (0, devkit_1.joinPathFragments)(ctContext.root, ctProjectConfig.root, 'cypress', 'support', 'component-index.html') : undefined, devServer: { // cypress uses string union type, // need to use const to prevent typing to string ...{ framework: 'angular', bundler: 'webpack', }, options: { projectConfig: buildableProjectConfig, }, }, }; } function getBuildableTarget(ctContext) { const targets = ctContext.projectGraph.nodes[ctContext.projectName]?.data?.targets; const targetConfig = targets?.[ctContext.targetName]; if (!targetConfig) { throw new Error((0, devkit_1.stripIndents) `Unable to find component testing target configuration in project '${ctContext.projectName}'. Has targets? ${!!targets} Has target name? ${ctContext.targetName} Has ct project name? ${ctContext.projectName} `); } const cypressCtOptions = (0, devkit_1.readTargetOptions)({ project: ctContext.projectName, target: ctContext.targetName, configuration: ctContext.configurationName, }, ctContext); if (!cypressCtOptions.devServerTarget) { throw new Error(`Unable to find the 'devServerTarget' executor option in the '${ctContext.targetName}' target of the '${ctContext.projectName}' project`); } return (0, devkit_1.parseTargetString)(cypressCtOptions.devServerTarget, ctContext.projectGraph); } function normalizeBuildTargetOptions(buildContext, ctContext, offset) { const options = (0, devkit_1.readTargetOptions)({ project: buildContext.projectName, target: buildContext.targetName, configuration: buildContext.configurationName, }, buildContext); const project = buildContext.projectsConfigurations.projects[buildContext.projectName]; const sourceRoot = (0, ts_solution_setup_1.getProjectSourceRoot)(project); const buildOptions = withSchemaDefaults(options, sourceRoot, buildContext.root); // cypress creates a tsconfig if one isn't preset // that contains all the support required for angular and component tests delete buildOptions.tsConfig; if (offset) { // polyfill entries might be local files or files that are resolved from node_modules // like zone.js. // prevents error from webpack saying can't find <offset>/zone.js. const handlePolyfillPath = (polyfill) => { const maybeFullPath = (0, path_1.join)(ctContext.root, polyfill.split('/').join(path_1.sep)); if ((0, fs_1.existsSync)(maybeFullPath)) { return (0, devkit_1.joinPathFragments)(offset, polyfill); } return polyfill; }; // paths need to be unix paths for angular devkit if (buildOptions.polyfills) { buildOptions.polyfills = Array.isArray(buildOptions.polyfills) && buildOptions.polyfills.length > 0 ? buildOptions.polyfills.map((p) => handlePolyfillPath(p)) : handlePolyfillPath(buildOptions.polyfills); } buildOptions.main = (0, devkit_1.joinPathFragments)(offset, buildOptions.main); buildOptions.index = typeof buildOptions.index === 'string' ? (0, devkit_1.joinPathFragments)(offset, buildOptions.index) : { ...buildOptions.index, input: (0, devkit_1.joinPathFragments)(offset, buildOptions.index.input), }; buildOptions.fileReplacements = buildOptions.fileReplacements.map((fr) => { fr.replace = (0, devkit_1.joinPathFragments)(offset, fr.replace); fr.with = (0, devkit_1.joinPathFragments)(offset, fr.with); return fr; }); } // if the ct project isn't being used in the build project // then we don't want to have the assets/scripts/styles be included to // prevent inclusion of unintended stuff like tailwind if (buildContext.projectName === ctContext.projectName || (0, ct_helpers_1.isCtProjectUsingBuildProject)(ctContext.projectGraph, buildContext.projectName, ctContext.projectName)) { if (offset) { buildOptions.assets = buildOptions.assets.map((asset) => { return typeof asset === 'string' ? (0, devkit_1.joinPathFragments)(offset, asset) : { ...asset, input: (0, devkit_1.joinPathFragments)(offset, asset.input) }; }); buildOptions.styles = buildOptions.styles.map((style) => { return typeof style === 'string' ? (0, devkit_1.joinPathFragments)(offset, style) : { ...style, input: (0, devkit_1.joinPathFragments)(offset, style.input) }; }); buildOptions.scripts = buildOptions.scripts.map((script) => { return typeof script === 'string' ? (0, devkit_1.joinPathFragments)(offset, script) : { ...script, input: (0, devkit_1.joinPathFragments)(offset, script.input) }; }); if (buildOptions.stylePreprocessorOptions?.includePaths.length > 0) { buildOptions.stylePreprocessorOptions = { includePaths: buildOptions.stylePreprocessorOptions.includePaths.map((path) => { return (0, devkit_1.joinPathFragments)(offset, path); }), }; } } } else { const stylePath = getTempStylesForTailwind(ctContext); buildOptions.styles = stylePath ? [stylePath] : []; buildOptions.assets = []; buildOptions.scripts = []; buildOptions.stylePreprocessorOptions = { includePaths: [] }; } return { root: offset ? (0, devkit_1.joinPathFragments)(offset, project.root) : project.root, sourceRoot: offset ? (0, devkit_1.joinPathFragments)(offset, sourceRoot) : sourceRoot, buildOptions: { ...buildOptions, // this property is only valid for cy v12.9.0+ workspaceRoot: offset ? undefined : ctContext.root, }, }; } function withSchemaDefaults(options, sourceRoot, workspaceRoot) { if (!options.main && !options.browser) { options.browser = (0, devkit_1.joinPathFragments)(sourceRoot, 'main.ts'); if (!(0, fs_1.existsSync)((0, path_1.join)(workspaceRoot, options.browser))) { throw new Error('Missing executor options "main" and "browser"'); } } if (!options.index) { throw new Error('Missing executor options "index"'); } if (!options.tsConfig) { throw new Error('Missing executor options "tsConfig"'); } // cypress defaults aot to false so we cannot use buildOptimizer // otherwise the 'buildOptimizer' cannot be used without 'aot' error is thrown options.buildOptimizer = false; options.aot = false; options.assets ??= []; options.allowedCommonJsDependencies ??= []; options.budgets ??= []; options.commonChunk ??= true; options.crossOrigin ??= 'none'; options.deleteOutputPath ??= true; options.extractLicenses ??= true; options.fileReplacements ??= []; options.inlineStyleLanguage ??= 'css'; options.i18nDuplicateTranslation ??= 'warning'; options.outputHashing ??= 'none'; options.progress ??= true; options.scripts ??= []; options.main ??= options.browser; return options; } /** * @returns a path from the workspace root to a temp file containing the base tailwind setup * if tailwind is being used in the project root or workspace root * this file should get cleaned up via the cypress executor */ function getTempStylesForTailwind(ctExecutorContext) { const ctProjectConfig = ctExecutorContext.projectGraph.nodes[ctExecutorContext.projectName]?.data; // angular only supports `tailwind.config.{js,cjs}` const ctProjectTailwindConfig = (0, path_1.join)(ctExecutorContext.root, ctProjectConfig.root, 'tailwind.config'); const exts = ['js', 'cjs']; const isTailWindInCtProject = exts.some((ext) => (0, fs_1.existsSync)(`${ctProjectTailwindConfig}.${ext}`)); const rootTailwindPath = (0, path_1.join)(ctExecutorContext.root, 'tailwind.config'); const isTailWindInRoot = exts.some((ext) => (0, fs_1.existsSync)(`${rootTailwindPath}.${ext}`)); if (isTailWindInRoot || isTailWindInCtProject) { const pathToStyle = (0, ct_helpers_1.getTempTailwindPath)(ctExecutorContext); try { (0, fs_1.mkdirSync)((0, path_1.dirname)(pathToStyle), { recursive: true }); (0, fs_1.writeFileSync)(pathToStyle, ` @tailwind base; @tailwind components; @tailwind utilities; `, { encoding: 'utf-8' }); return pathToStyle; } catch (makeTmpFileError) { devkit_1.logger.warn((0, devkit_1.stripIndents) `Issue creating a temp file for tailwind styles. Defaulting to no tailwind setup. Temp file path? ${pathToStyle}`); devkit_1.logger.error(makeTmpFileError); } } } function isOffsetNeeded(ctExecutorContext, ctProjectConfig) { try { const supportsWorkspaceRoot = isCyVersionGreaterThanOrEqual('12.9.0'); // if using cypress <v12.9.0 then we require the offset if (!supportsWorkspaceRoot) { return true; } if (ctProjectConfig.projectType === 'library' && // angular will only see this config if the library root is the build project config root // otherwise it will be set to the buildTarget root which is the app root where this config doesn't exist // causing tailwind styles from the libs project root to not work ['js', 'cjs'].some((ext) => (0, fs_1.existsSync)((0, path_1.join)(ctExecutorContext.root, ctProjectConfig.root, `tailwind.config.${ext}`)))) { return true; } return false; } catch (e) { if (process.env.NX_VERBOSE_LOGGING === 'true') { devkit_1.logger.error(e); } // unable to determine if we don't require an offset // safest to assume we do return true; } } /** * check if the cypress version is able to understand absolute paths to the indexHtmlFile option * this is required for nx to work with cypress <v12.17.0 since the relative pathing is causes issues * with invalid pathing. * v12.17.0+ works with relative pathing * * if there is an error thrown then we assume it is an older version of cypress and use the absolute path * as that was supported for longer. * * */ function requiresAbsolutePath() { try { return !isCyVersionGreaterThanOrEqual('12.17.0'); } catch (e) { if (process.env.NX_VERBOSE_LOGGING === 'true') { devkit_1.logger.error(e); } return true; } } /** * Checks if the install cypress version is greater than or equal to the provided version. * Does not catch errors as any custom logic for error handling is required on consumer side. * */ function isCyVersionGreaterThanOrEqual(version) { const { version: cyVersion = null } = require('cypress/package.json'); return !!cyVersion && (0, semver_1.gte)(cyVersion, version); }