b6fc86ef14
## Current Behavior
`@nx/vitest`, `@nx/cypress`, `@nx/playwright`, and `@nx/vite` all build
to the workspace-root `dist/packages/<name>/` directory and publish from
there. They use the legacy `module: commonjs` / `moduleResolution: node`
configuration, an `exports` map with a `./src/*` wildcard
(vitest/cypress), and a `nx-release-publish.options.packageRoot` that
points at the workspace-root dist tree. This is the same shape
`@nx/devkit`, `@nx/nx`, `@nx/js`, `@nx/eslint`, and `@nx/jest` already
moved off — but the M2 group of testing/build packages was still on the
old layout, blocking downstream packages (like `@nx/web`) from
migrating.
Separately, the 23.0.0 `rewrite-internal-subpath-imports` codemods
shipped with `@nx/js`, `@nx/jest`, `@nx/eslint` (and the new one in this
PR for `@nx/cypress`) walk `require(...)` / dynamic `import(...)` /
`jest.mock(...)` call expressions but leave `typeof
import('@nx/<name>/src/x')` type queries untouched. The common
typed-runtime-require idiom
```ts
const m = require('@nx/<name>/src/x') as typeof import('@nx/<name>/src/x');
```
would have its runtime arg rewritten to `/internal` while the type arg
kept pointing at the now-removed `./src/*` wildcard — leaving external
consumers with a TS error after running the migration.
## Expected Behavior
Each of the four packages now builds to `packages/<name>/dist/` with
`module: nodenext` + composite TypeScript, ships a `files` field in
`package.json` listing what to publish, declares
`release.preserveLocalDependencyProtocols: true` +
`manifestRootsToUpdate: ["packages/{projectName}"]` in `project.json`,
and publishes straight from the package directory via
`nx-release-publish.options.packageRoot: packages/{projectName}`.
Per-package highlights:
- **`@nx/vitest`** — straight structural migration. No
`@nx/vitest/src/*` consumers, so no codemod migration shipped.
`assets.json` updated to glob `src/migrations/**/*.md` so prompt-form
migrations resolve from `./dist/src/...`.
- **`@nx/cypress`** — drops the `./src/*` wildcard from the exports map,
ships a curated `internal.ts` re-export entry, and ships a
`23.0.0-beta.17` symbol-aware codemod
(`rewrite-internal-subpath-imports`) that routes `@nx/cypress/src/*`
imports to either the public `@nx/cypress` entry (for
`configurationGenerator`, `componentConfigurationGenerator`,
`cypressInitGenerator`, `migrateCypressProject`) or
`@nx/cypress/internal` (for everything else). 10 first-party consumers
in `@nx/angular` / `@nx/react` / `@nx/next` / `@nx/web` are codemodded
to match.
- **`@nx/playwright`** — straight structural migration; existing exports
already enumerated explicit subpaths, no `./src/*` wildcard to drop.
- **`@nx/vite`** — structural migration + three executor `.impl.ts`
files switched from `const schema = await import('./schema.json')` to a
top-level `import schema from './schema.json'` (required under
`nodenext`). Kept `./plugins/nx-tsconfig-paths.plugin` /
`./plugins/nx-copy-assets.plugin` /
`./plugins/rollup-replace-files.plugin` as explicit public entries
because storybook / react-native templates bake them into generated user
vite configs.
The fourth commit fixes the `typeof import()` blind spot across **all
four** of the 23.0.0 subpath-rewrite codemods (`@nx/js`, `@nx/jest`,
`@nx/eslint`, the new `@nx/cypress` one) by walking `ImportTypeNode` and
rewriting the literal-type-node argument when it points into the
package's `src/`. The cypress spec also adds explicit coverage for
`componentConfigurationGenerator` as a public-routed symbol, default and
default-plus-named imports, `jest.mock(..., factory)`, and
`it.each(MOCK_HELPER_METHODS)` for both the jest and vi mock families —
drift between those hardcoded sets and the real public/mock surface was
the most plausible future silent-regression path. The
dist-build-migration skill is updated to document the `ImportTypeNode`
handling and the expanded spec checklist.
## Validation
- `pnpm nx run-many -t build-base -p
angular,nuxt,remix,vue,web,react,vitest,cypress,playwright,vite,jest,js,eslint`
✅
- `pnpm nx affected -t build,lint --base=origin/master` ✅ (53 projects,
103 tasks).
- `pnpm nx run-many -t test -p jest,js,eslint,cypress
--testPathPatterns="rewrite-internal-subpath-imports"` ✅ — 99 passing
(32 cypress, 27 eslint, 20 jest, 20 js).
`@nx/web` is now unblocked on 4 of its 5 prior dist-build dependencies
(`@nx/vitest`, `@nx/cypress`, `@nx/playwright`, `@nx/vite`); only
`@nx/webpack` remains on the old layout.
## Related Issue(s)
Linear: [NXC-3581](https://linear.app/nxdev/issue/NXC-3581) — M2 epic,
migrate testing/build packages to local dist.
<details>
<summary>Pre-create review (run before PR open)</summary>
### Critical
- (Fixed in this PR) Codemod skipped `typeof
import('@nx/<name>/src/...')` type queries — backported the fix to
`@nx/js`, `@nx/jest`, `@nx/eslint`, and the new `@nx/cypress` migration.
- (Fixed in this PR) Spec didn't exercise
`componentConfigurationGenerator` (the 4th public symbol), default
imports, or `jest.mock` / `vi.mock` proper.
### Important
- `@nx/vite` / `@nx/vitest` / `@nx/playwright` ship no codemod despite
dropping their `./src/*` wildcards. No first-party consumers exist;
external plugin authors using deep subpaths will hit breakage. Noted as
a known breaking change for the upgrade guide rather than fixed in this
PR — codemods can land in a follow-up if the surface turns out to
matter.
- Empty `try { ... } catch {}` in `cypress-version.ts` (pre-existing;
diff only touched JSDoc). Tracked for a follow-up.
### Suggestions
- Vite executor schema imports are now eager at module load (theoretical
risk; matches the jest precedent).
- `vite/project.json` and `playwright/project.json` omit `dependsOn:
["^build", "build-base"]` while cypress/vitest include it (covered by
`nx.json` targetDefaults, cosmetic).
- `cypress` dropped legacy aliases `./generators` / `./executors` /
`./migrations` (no `.json` suffix) from the exports map — undocumented
surface, low risk.
</details>
---------
Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com>
Co-authored-by: FrozenPandaz <FrozenPandaz@users.noreply.github.com>
462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
import {
|
|
nxBaseCypressPreset,
|
|
NxComponentTestingPresetOptions,
|
|
} from '@nx/cypress/plugins/cypress-preset';
|
|
import {
|
|
createExecutorContext,
|
|
getProjectConfigByPath,
|
|
getTempTailwindPath,
|
|
isCtProjectUsingBuildProject,
|
|
} from '@nx/cypress/internal';
|
|
import {
|
|
ExecutorContext,
|
|
joinPathFragments,
|
|
logger,
|
|
offsetFromRoot,
|
|
parseTargetString,
|
|
ProjectConfiguration,
|
|
ProjectGraph,
|
|
readCachedProjectGraph,
|
|
readTargetOptions,
|
|
stripIndents,
|
|
} from '@nx/devkit';
|
|
import { getProjectSourceRoot } from '@nx/js/internal';
|
|
import { existsSync, lstatSync, mkdirSync, writeFileSync } from 'fs';
|
|
import { dirname, join, relative, sep } from 'path';
|
|
import { gte } from 'semver';
|
|
import type { BrowserBuilderSchema } from '../src/builders/webpack-browser/schema';
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export function nxComponentTestingPreset(
|
|
pathToConfig: string,
|
|
options?: NxComponentTestingPresetOptions
|
|
) {
|
|
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 nxBaseCypressPreset(pathToConfig, {
|
|
testingType: 'component',
|
|
}) as any;
|
|
}
|
|
|
|
let graph: ProjectGraph;
|
|
try {
|
|
graph = 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 = getProjectConfigByPath(graph, pathToConfig);
|
|
const ctConfigurationName = process.env.NX_CYPRESS_TARGET_CONFIGURATION;
|
|
const ctContext = createExecutorContext(
|
|
graph,
|
|
ctProjectConfig.targets,
|
|
ctProjectConfig.name,
|
|
options?.ctTargetName || 'component-test',
|
|
ctConfigurationName
|
|
);
|
|
|
|
const buildTarget = options?.buildTarget
|
|
? 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(stripIndents`Unable to find project configuration for build target.
|
|
Project Name? ${buildTarget.project}
|
|
Has project config? ${!!graph.nodes?.[buildTarget.project]?.data}`);
|
|
}
|
|
|
|
const fromWorkspaceRoot = relative(ctContext.root, pathToConfig);
|
|
const normalizedFromWorkspaceRootPath = lstatSync(pathToConfig).isFile()
|
|
? dirname(fromWorkspaceRoot)
|
|
: fromWorkspaceRoot;
|
|
const offset = isOffsetNeeded(ctContext, ctProjectConfig)
|
|
? offsetFromRoot(normalizedFromWorkspaceRootPath)
|
|
: undefined;
|
|
|
|
const buildContext = createExecutorContext(
|
|
graph,
|
|
graph.nodes[buildTarget.project]?.data.targets,
|
|
buildTarget.project,
|
|
buildTarget.target,
|
|
buildTarget.configuration
|
|
);
|
|
|
|
const buildableProjectConfig = normalizeBuildTargetOptions(
|
|
buildContext,
|
|
ctContext,
|
|
offset
|
|
);
|
|
|
|
return {
|
|
...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()
|
|
? 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',
|
|
} as const),
|
|
options: {
|
|
projectConfig: buildableProjectConfig,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function getBuildableTarget(ctContext: ExecutorContext) {
|
|
const targets =
|
|
ctContext.projectGraph.nodes[ctContext.projectName]?.data?.targets;
|
|
const targetConfig = targets?.[ctContext.targetName];
|
|
|
|
if (!targetConfig) {
|
|
throw new Error(
|
|
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 = 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 parseTargetString(
|
|
cypressCtOptions.devServerTarget,
|
|
ctContext.projectGraph
|
|
);
|
|
}
|
|
|
|
function normalizeBuildTargetOptions(
|
|
buildContext: ExecutorContext,
|
|
ctContext: ExecutorContext,
|
|
offset?: string
|
|
): {
|
|
root: string;
|
|
sourceRoot: string;
|
|
buildOptions: BrowserBuilderSchema & { workspaceRoot: string };
|
|
} {
|
|
const options = readTargetOptions<BrowserBuilderSchema>(
|
|
{
|
|
project: buildContext.projectName,
|
|
target: buildContext.targetName,
|
|
configuration: buildContext.configurationName,
|
|
},
|
|
buildContext
|
|
);
|
|
const project =
|
|
buildContext.projectsConfigurations.projects[buildContext.projectName];
|
|
const sourceRoot = 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: string) => {
|
|
const maybeFullPath = join(ctContext.root, polyfill.split('/').join(sep));
|
|
if (existsSync(maybeFullPath)) {
|
|
return 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 as string[]).map((p) =>
|
|
handlePolyfillPath(p)
|
|
)
|
|
: handlePolyfillPath(buildOptions.polyfills as string);
|
|
}
|
|
buildOptions.main = joinPathFragments(offset, buildOptions.main);
|
|
buildOptions.index =
|
|
typeof buildOptions.index === 'string'
|
|
? joinPathFragments(offset, buildOptions.index)
|
|
: {
|
|
...buildOptions.index,
|
|
input: joinPathFragments(offset, buildOptions.index.input),
|
|
};
|
|
buildOptions.fileReplacements = buildOptions.fileReplacements.map((fr) => {
|
|
fr.replace = joinPathFragments(offset, fr.replace);
|
|
fr.with = 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 ||
|
|
isCtProjectUsingBuildProject(
|
|
ctContext.projectGraph,
|
|
buildContext.projectName,
|
|
ctContext.projectName
|
|
)
|
|
) {
|
|
if (offset) {
|
|
buildOptions.assets = buildOptions.assets.map((asset) => {
|
|
return typeof asset === 'string'
|
|
? joinPathFragments(offset, asset)
|
|
: { ...asset, input: joinPathFragments(offset, asset.input) };
|
|
});
|
|
buildOptions.styles = buildOptions.styles.map((style) => {
|
|
return typeof style === 'string'
|
|
? joinPathFragments(offset, style)
|
|
: { ...style, input: joinPathFragments(offset, style.input) };
|
|
});
|
|
buildOptions.scripts = buildOptions.scripts.map((script) => {
|
|
return typeof script === 'string'
|
|
? joinPathFragments(offset, script)
|
|
: { ...script, input: joinPathFragments(offset, script.input) };
|
|
});
|
|
if (buildOptions.stylePreprocessorOptions?.includePaths.length > 0) {
|
|
buildOptions.stylePreprocessorOptions = {
|
|
includePaths: buildOptions.stylePreprocessorOptions.includePaths.map(
|
|
(path) => {
|
|
return joinPathFragments(offset, path);
|
|
}
|
|
),
|
|
};
|
|
}
|
|
}
|
|
} else {
|
|
const stylePath = getTempStylesForTailwind(ctContext);
|
|
buildOptions.styles = stylePath ? [stylePath] : [];
|
|
buildOptions.assets = [];
|
|
buildOptions.scripts = [];
|
|
buildOptions.stylePreprocessorOptions = { includePaths: [] };
|
|
}
|
|
|
|
return {
|
|
root: offset ? joinPathFragments(offset, project.root) : project.root,
|
|
sourceRoot: offset ? joinPathFragments(offset, sourceRoot) : sourceRoot,
|
|
buildOptions: {
|
|
...buildOptions,
|
|
// this property is only valid for cy v12.9.0+
|
|
workspaceRoot: offset ? undefined : ctContext.root,
|
|
},
|
|
};
|
|
}
|
|
|
|
function withSchemaDefaults(
|
|
options: any,
|
|
sourceRoot: string,
|
|
workspaceRoot: string
|
|
): BrowserBuilderSchema {
|
|
if (!options.main && !options.browser) {
|
|
options.browser = joinPathFragments(sourceRoot, 'main.ts');
|
|
if (!existsSync(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: ExecutorContext) {
|
|
const ctProjectConfig = ctExecutorContext.projectGraph.nodes[
|
|
ctExecutorContext.projectName
|
|
]?.data as ProjectConfiguration;
|
|
// angular only supports `tailwind.config.{js,cjs}`
|
|
const ctProjectTailwindConfig = join(
|
|
ctExecutorContext.root,
|
|
ctProjectConfig.root,
|
|
'tailwind.config'
|
|
);
|
|
const exts = ['js', 'cjs'];
|
|
const isTailWindInCtProject = exts.some((ext) =>
|
|
existsSync(`${ctProjectTailwindConfig}.${ext}`)
|
|
);
|
|
const rootTailwindPath = join(ctExecutorContext.root, 'tailwind.config');
|
|
const isTailWindInRoot = exts.some((ext) =>
|
|
existsSync(`${rootTailwindPath}.${ext}`)
|
|
);
|
|
|
|
if (isTailWindInRoot || isTailWindInCtProject) {
|
|
const pathToStyle = getTempTailwindPath(ctExecutorContext);
|
|
try {
|
|
mkdirSync(dirname(pathToStyle), { recursive: true });
|
|
writeFileSync(
|
|
pathToStyle,
|
|
`
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
return pathToStyle;
|
|
} catch (makeTmpFileError) {
|
|
logger.warn(stripIndents`Issue creating a temp file for tailwind styles. Defaulting to no tailwind setup.
|
|
Temp file path? ${pathToStyle}`);
|
|
logger.error(makeTmpFileError);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isOffsetNeeded(
|
|
ctExecutorContext: ExecutorContext,
|
|
ctProjectConfig: ProjectConfiguration
|
|
) {
|
|
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) =>
|
|
existsSync(
|
|
join(
|
|
ctExecutorContext.root,
|
|
ctProjectConfig.root,
|
|
`tailwind.config.${ext}`
|
|
)
|
|
)
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch (e) {
|
|
if (process.env.NX_VERBOSE_LOGGING === 'true') {
|
|
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') {
|
|
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: string) {
|
|
const { version: cyVersion = null } = require('cypress/package.json');
|
|
|
|
return !!cyVersion && gte(cyVersion, version);
|
|
}
|