fix(testing): handle more complex projects for react component testing (#11725)

* fix(testing): use @nrwl/web:webpack utils to generate a more robust webpack config

fixes: #11372

* fix(testing): do not overwrite existing component test

* fix(testing): add component-test to cacheable operations

* chore(testing): address pr feedback
This commit is contained in:
Caleb Ukle 2022-08-30 11:42:42 -05:00 committed by GitHub
parent 7c8313504c
commit c7249db386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1587 additions and 288 deletions

View File

@ -1207,7 +1207,7 @@
"name": "cypress-component-configuration", "name": "cypress-component-configuration",
"factory": "./src/generators/cypress-component-configuration/cypress-component-configuration#cypressComponentConfigGenerator", "factory": "./src/generators/cypress-component-configuration/cypress-component-configuration#cypressComponentConfigGenerator",
"schema": { "schema": {
"$schema": "http://json-schema.org/schema", "$schema": "https://json-schema.org/schema",
"cli": "nx", "cli": "nx",
"$id": "NxReactCypressComponentTestConfiguration", "$id": "NxReactCypressComponentTestConfiguration",
"title": "Add Cypress component testing", "title": "Add Cypress component testing",
@ -1230,6 +1230,11 @@
"$default": { "$source": "projectName" }, "$default": { "$source": "projectName" },
"x-prompt": "What project should we add Cypress component testing to?" "x-prompt": "What project should we add Cypress component testing to?"
}, },
"buildTarget": {
"type": "string",
"description": "A build target used to configure Cypress component testing in the format of `project:target[:configuration]`. The build target should be from a React app. If not provided we will try to infer it from your projects usage.",
"pattern": "^[^:\\s]+:[^:\\s]+(:\\S+)?$"
},
"generateTests": { "generateTests": {
"type": "boolean", "type": "boolean",
"description": "Generate default component tests for existing components in the project", "description": "Generate default component tests for existing components in the project",

View File

@ -1,4 +1,4 @@
import { newProject, runCLI, uniq } from '../../utils'; import { createFile, newProject, runCLI, uniq, updateFile } from '../../utils';
describe('React Cypress Component Tests', () => { describe('React Cypress Component Tests', () => {
beforeAll(() => newProject()); beforeAll(() => newProject());
@ -17,14 +17,35 @@ describe('React Cypress Component Tests', () => {
); );
}, 1000000); }, 1000000);
it('should successfully test react app', () => { it('should successfully test react lib', () => {
const libName = uniq('cy-react-lib'); const libName = uniq('cy-react-lib');
const appName = uniq('cy-react-app-target');
runCLI(`generate @nrwl/react:app ${appName} --no-interactive`);
runCLI(`generate @nrwl/react:lib ${libName} --component --no-interactive`); runCLI(`generate @nrwl/react:lib ${libName} --component --no-interactive`);
runCLI(
`generate @nrwl/react:setup-tailwind --project=${libName} --no-interactive`
);
runCLI( runCLI(
`generate @nrwl/react:component fancy-component --project=${libName} --no-interactive` `generate @nrwl/react:component fancy-component --project=${libName} --no-interactive`
); );
createFile(
`libs/${libName}/src/styles.css`,
`
@tailwind components;
@tailwind base;
@tailwind utilities;
`
);
updateFile(
`libs/${libName}/src/lib/fancy-component/fancy-component.tsx`,
(content) => {
return `
import '../../styles.css';
${content}`;
}
);
runCLI( runCLI(
`generate @nrwl/react:cypress-component-configuration --project=${libName} --generate-tests` `generate @nrwl/react:cypress-component-configuration --project=${libName} --build-target=${appName}:build --generate-tests`
); );
expect(runCLI(`component-test ${libName} --no-watch`)).toContain( expect(runCLI(`component-test ${libName} --no-watch`)).toContain(
'All specs passed!' 'All specs passed!'

View File

@ -1,6 +1,14 @@
// mock so we can test multiple versions // mock so we can test multiple versions
jest.mock('@nrwl/cypress/src/utils/cypress-version'); jest.mock('@nrwl/cypress/src/utils/cypress-version');
// mock bc the nxE2EPreset uses fs for path normalization
jest.mock('fs', () => {
return {
...jest.requireActual('fs'),
lstatSync: jest.fn(() => ({
isDirectory: jest.fn(() => true),
})),
};
});
import { installedCypressVersion } from '@nrwl/cypress/src/utils/cypress-version'; import { installedCypressVersion } from '@nrwl/cypress/src/utils/cypress-version';
import { import {
joinPathFragments, joinPathFragments,
@ -13,6 +21,7 @@ import {
writeJson, writeJson,
} from '@nrwl/devkit'; } from '@nrwl/devkit';
import { createTreeWithEmptyV1Workspace } from '@nrwl/devkit/testing'; import { createTreeWithEmptyV1Workspace } from '@nrwl/devkit/testing';
import { lstatSync } from 'fs';
import { E2eMigrator } from './e2e.migrator'; import { E2eMigrator } from './e2e.migrator';
import { MigrationProjectConfiguration } from './types'; import { MigrationProjectConfiguration } from './types';

View File

@ -562,7 +562,7 @@ export class E2eMigrator extends ProjectMigrator<SupportedTargets> {
} }
private updateCypress10ConfigFile(configFilePath: string): void { private updateCypress10ConfigFile(configFilePath: string): void {
this.cypressPreset = nxE2EPreset(this.project.newRoot); this.cypressPreset = nxE2EPreset(configFilePath);
const fileContent = this.tree.read(configFilePath, 'utf-8'); const fileContent = this.tree.read(configFilePath, 'utf-8');
let sourceFile = tsquery.ast(fileContent); let sourceFile = tsquery.ast(fileContent);

View File

@ -30,6 +30,12 @@
"version": "12.8.0-beta.0", "version": "12.8.0-beta.0",
"description": "Remove Typescript Preprocessor Plugin", "description": "Remove Typescript Preprocessor Plugin",
"factory": "./src/migrations/update-12-8-0/remove-typescript-plugin" "factory": "./src/migrations/update-12-8-0/remove-typescript-plugin"
},
"update-cypress-configs-preset": {
"cli": "nx",
"version": "14.6.1-beta.0",
"description": "Change Cypress e2e and component testing presets to use __filename instead of __dirname and include a devServerTarget for component testing.",
"factory": "./src/migrations/update-14-6-1/update-cypress-configs-presets"
} }
}, },
"packageJsonUpdates": { "packageJsonUpdates": {

View File

@ -1,5 +1,6 @@
import { workspaceRoot } from '@nrwl/devkit'; import { workspaceRoot } from '@nrwl/devkit';
import { join, relative } from 'path'; import { dirname, join, relative } from 'path';
import { lstatSync } from 'fs';
interface BaseCypressPreset { interface BaseCypressPreset {
videosFolder: string; videosFolder: string;
@ -9,8 +10,13 @@ interface BaseCypressPreset {
} }
export function nxBaseCypressPreset(pathToConfig: string): BaseCypressPreset { export function nxBaseCypressPreset(pathToConfig: string): BaseCypressPreset {
const projectPath = relative(workspaceRoot, pathToConfig); // prevent from placing path outside the root of the workspace
const offset = relative(pathToConfig, workspaceRoot); // if they pass in a file or directory
const normalizedPath = lstatSync(pathToConfig).isDirectory()
? pathToConfig
: dirname(pathToConfig);
const projectPath = relative(workspaceRoot, normalizedPath);
const offset = relative(normalizedPath, workspaceRoot);
const videosFolder = join(offset, 'dist', 'cypress', projectPath, 'videos'); const videosFolder = join(offset, 'dist', 'cypress', projectPath, 'videos');
const screenshotsFolder = join( const screenshotsFolder = join(
offset, offset,

View File

@ -45,7 +45,8 @@ export default async function cypressExecutor(
context: ExecutorContext context: ExecutorContext
) { ) {
options = normalizeOptions(options, context); options = normalizeOptions(options, context);
// this is used by cypress component testing presets to build the executor contexts with the correct configuration options.
process.env.NX_CYPRESS_TARGET_CONFIGURATION = context.configurationName;
let success; let success;
for await (const baseUrl of startDevServer(options, context)) { for await (const baseUrl of startDevServer(options, context)) {

View File

@ -1,6 +1,7 @@
import { import {
addProjectConfiguration, addProjectConfiguration,
ProjectConfiguration, ProjectConfiguration,
readJson,
readProjectConfiguration, readProjectConfiguration,
Tree, Tree,
updateProjectConfiguration, updateProjectConfiguration,
@ -118,6 +119,19 @@ describe('Cypress Component Project', () => {
expect(projectConfig.targets['component-test']).toMatchSnapshot(); expect(projectConfig.targets['component-test']).toMatchSnapshot();
}); });
it('should update cacheable operations', async () => {
mockedInstalledCypressVersion.mockReturnValue(10);
await cypressComponentProject(tree, {
project: 'cool-lib',
skipFormat: false,
});
expect(
readJson(tree, 'nx.json').tasksRunnerOptions.default.options
.cacheableOperations
).toEqual(expect.arrayContaining(['component-test']));
});
it('should not error when rerunning on an existing project', async () => { it('should not error when rerunning on an existing project', async () => {
mockedInstalledCypressVersion.mockReturnValue(10); mockedInstalledCypressVersion.mockReturnValue(10);
tree.write('libs/cool-lib/cypress.config.ts', ''); tree.write('libs/cool-lib/cypress.config.ts', '');

View File

@ -7,7 +7,9 @@ import {
ProjectConfiguration, ProjectConfiguration,
readProjectConfiguration, readProjectConfiguration,
Tree, Tree,
updateJson,
updateProjectConfiguration, updateProjectConfiguration,
NxJsonConfiguration,
} from '@nrwl/devkit'; } from '@nrwl/devkit';
import { installedCypressVersion } from '../../utils/cypress-version'; import { installedCypressVersion } from '../../utils/cypress-version';
@ -35,7 +37,7 @@ export async function cypressComponentProject(
addProjectFiles(tree, projectConfig, options); addProjectFiles(tree, projectConfig, options);
addTargetToProject(tree, projectConfig, options); addTargetToProject(tree, projectConfig, options);
addToCacheableOperations(tree);
if (!options.skipFormat) { if (!options.skipFormat) {
await formatFiles(tree); await formatFiles(tree);
} }
@ -87,3 +89,25 @@ function addTargetToProject(
updateProjectConfiguration(tree, options.project, projectConfig); updateProjectConfiguration(tree, options.project, projectConfig);
} }
export function addToCacheableOperations(tree: Tree) {
updateJson(tree, 'nx.json', (json) => ({
...json,
tasksRunnerOptions: {
...json.tasksRunnerOptions,
default: {
...json.tasksRunnerOptions?.default,
options: {
...json.tasksRunnerOptions?.default?.options,
cacheableOperations: Array.from(
new Set([
...(json.tasksRunnerOptions?.default?.options
?.cacheableOperations ?? []),
'component-test',
])
),
},
},
},
}));
}

View File

@ -0,0 +1,415 @@
import { updateCypressConfigsPresets } from './update-cypress-configs-presets';
import { installedCypressVersion } from '../../utils/cypress-version';
import {
addProjectConfiguration,
DependencyType,
logger,
ProjectGraph,
readProjectConfiguration,
Tree,
updateProjectConfiguration,
} from '@nrwl/devkit';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import { cypressProjectGenerator } from '../../generators/cypress-project/cypress-project';
import { libraryGenerator } from '@nrwl/workspace';
let projectGraph: ProjectGraph;
jest.mock('@nrwl/devkit', () => {
return {
...jest.requireActual('@nrwl/devkit'),
createProjectGraphAsync: jest.fn().mockImplementation(() => projectGraph),
};
});
jest.mock('../../utils/cypress-version');
describe('updateComponentTestingConfig', () => {
let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion>
> = installedCypressVersion as never;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should update', async () => {
mockedInstalledCypressVersion.mockReturnValue(10);
await setup(tree, { name: 'something' });
await updateCypressConfigsPresets(tree);
expect(
tree.read('libs/something-lib/cypress.config.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__filename),
});
`
);
expect(
tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }),
});
`
);
expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8'))
.toContain(`export default defineConfig({
e2e: nxE2EPreset(__filename),
});
`);
expect(tree.read('apps/something-e2e/cypress.storybook-config.ts', 'utf-8'))
.toContain(`export default defineConfig({
e2e: nxE2EStorybookPreset(__filename),
});
`);
const libProjectConfig = readProjectConfiguration(tree, 'something-lib');
expect(libProjectConfig.targets['component-test']).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/something-lib/cypress.config.ts',
testingType: 'component',
devServerTarget: 'something-app:build',
skipServe: true,
},
});
expect(libProjectConfig.targets['ct']).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/something-lib/cypress.config-two.ts',
testingType: 'component',
devServerTarget: 'something-app:build',
skipServe: true,
},
configurations: {
prod: {
baseUrl: 'https://example.com',
},
},
});
});
it('should list out projects when unable to update config', async () => {
const loggerSpy = jest.spyOn(logger, 'warn');
await setup(tree, { name: 'something' });
projectGraph = {
nodes: {},
dependencies: {},
};
await updateCypressConfigsPresets(tree);
expect(loggerSpy.mock.calls).toEqual([
[
'Unable to find a build target to add to the component testing target in the following projects:',
],
['- something-lib'],
[
`You can manually add the 'devServerTarget' option to the
component testing target to specify the build target to use.
The build configuration should be using @nrwl/web:webpack as the executor.
Usually this is a React app in your workspace.
Component testing will fallback to a default configuration if one isn't provided,
but might require modifications if your projects are more complex.`,
],
]);
});
it('should handle already updated config', async () => {
mockedInstalledCypressVersion.mockReturnValue(10);
await setup(tree, { name: 'something' });
expect(async () => {
await updateCypressConfigsPresets(tree);
}).not.toThrow();
expect(tree.read('libs/something-lib/cypress.config.ts', 'utf-8'))
.toContain(`export default defineConfig({
component: nxComponentTestingPreset(__filename),
});
`);
expect(
tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }),
});
`
);
expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8'))
.toContain(`export default defineConfig({
e2e: nxE2EPreset(__filename),
});
`);
});
it('should not update if using < v10', async () => {
mockedInstalledCypressVersion.mockReturnValue(9);
await setup(tree, { name: 'something' });
await updateCypressConfigsPresets(tree);
expect(
tree.read('libs/something-lib/cypress.config.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__dirname),
});
`
);
expect(
tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__dirname),
});
`
);
expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8'))
.toContain(`export default defineConfig({
e2e: nxE2EPreset(__dirname),
});
`);
});
it('should be idempotent', async () => {
mockedInstalledCypressVersion.mockReturnValue(10);
await setup(tree, { name: 'something' });
await updateCypressConfigsPresets(tree);
expect(
tree.read('libs/something-lib/cypress.config.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__filename),
});
`
);
expect(
tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }),
});
`
);
expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8'))
.toContain(`export default defineConfig({
e2e: nxE2EPreset(__filename),
});
`);
const libProjectConfig = readProjectConfiguration(tree, 'something-lib');
expect(libProjectConfig.targets['component-test']).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/something-lib/cypress.config.ts',
testingType: 'component',
devServerTarget: 'something-app:build',
skipServe: true,
},
});
expect(libProjectConfig.targets['ct']).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/something-lib/cypress.config-two.ts',
testingType: 'component',
devServerTarget: 'something-app:build',
skipServe: true,
},
configurations: {
prod: {
baseUrl: 'https://example.com',
},
},
});
await updateCypressConfigsPresets(tree);
expect(
tree.read('libs/something-lib/cypress.config.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__filename),
});
`
);
expect(
tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8')
).toContain(
`export default defineConfig({
component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }),
});
`
);
expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8'))
.toContain(`export default defineConfig({
e2e: nxE2EPreset(__filename),
});
`);
const libProjectConfig2 = readProjectConfiguration(tree, 'something-lib');
expect(libProjectConfig2.targets['component-test']).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/something-lib/cypress.config.ts',
testingType: 'component',
devServerTarget: 'something-app:build',
skipServe: true,
},
});
expect(libProjectConfig2.targets['ct']).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/something-lib/cypress.config-two.ts',
testingType: 'component',
devServerTarget: 'something-app:build',
skipServe: true,
},
configurations: {
prod: {
baseUrl: 'https://example.com',
},
},
});
});
});
async function setup(tree: Tree, options: { name: string }) {
const appName = `${options.name}-app`;
const libName = `${options.name}-lib`;
const e2eName = `${options.name}-e2e`;
tree.write(
'apps/my-app/cypress.config.ts',
`import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nrwl/cypress/plugins/component-testing';
export default defineConfig({
component: nxComponentTestingPreset(__dirname),
});
`
);
addProjectConfiguration(tree, appName, {
root: `apps/my-app`,
sourceRoot: `apps/${appName}/src`,
targets: {
build: {
executor: '@nrwl/web:webpack',
outputs: ['{options.outputPath}'],
options: {
compiler: 'babel',
outputPath: `dist/apps/${appName}`,
index: `apps/${appName}/src/index.html`,
baseHref: '/',
main: `apps/${appName}/src/main.tsx`,
polyfills: `apps/${appName}/src/polyfills.ts`,
tsConfig: `apps/${appName}/tsconfig.app.json`,
},
},
},
});
await cypressProjectGenerator(tree, { project: appName, name: e2eName });
const e2eProjectConfig = readProjectConfiguration(tree, e2eName);
e2eProjectConfig.targets['e2e'].configurations = {
...e2eProjectConfig.targets['e2e'].configurations,
sb: {
cypressConfig: `apps/${e2eName}/cypress.storybook-config.ts`,
},
};
updateProjectConfiguration(tree, e2eName, e2eProjectConfig);
tree.write(
`apps/${e2eName}/cypress.config.ts`,
`import { defineConfig } from 'cypress';
import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset';
export default defineConfig({
e2e: nxE2EPreset(__dirname),
});
`
);
tree.write(
`apps/${e2eName}/cypress.storybook-config.ts`,
`
import { defineConfig } from 'cypress';
import { nxE2EStorybookPreset } from '@nrwl/cypress/plugins/cypress-preset';
export default defineConfig({
e2e: nxE2EStorybookPreset(__dirname),
});
`
);
// lib
await libraryGenerator(tree, { name: libName });
const libProjectConfig = readProjectConfiguration(tree, libName);
libProjectConfig.targets = {
...libProjectConfig.targets,
'component-test': {
executor: '@nrwl/cypress:cypress',
options: {
testingType: 'component',
cypressConfig: `libs/${libName}/cypress.config.ts`,
},
},
ct: {
executor: '@nrwl/cypress:cypress',
options: {
testingType: 'component',
cypressConfig: `libs/${libName}/cypress.config-two.ts`,
},
configurations: {
prod: {
baseUrl: 'https://example.com',
},
},
},
};
updateProjectConfiguration(tree, libName, libProjectConfig);
tree.write(
`libs/${libName}/cypress.config.ts`,
`import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nrwl/cypress/plugins/component-testing';
export default defineConfig({
component: nxComponentTestingPreset(__dirname),
});
`
);
tree.write(
`libs/${libName}/cypress.config-two.ts`,
`import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nrwl/cypress/plugins/component-testing';
export default defineConfig({
component: nxComponentTestingPreset(__dirname),
});
`
);
projectGraph = {
nodes: {
[appName]: {
name: appName,
type: 'app',
data: {
...readProjectConfiguration(tree, appName),
},
},
[e2eName]: {
name: e2eName,
type: 'e2e',
data: {
...readProjectConfiguration(tree, e2eName),
},
},
[libName]: {
name: libName,
type: 'lib',
data: {
...readProjectConfiguration(tree, libName),
},
},
},
dependencies: {
[appName]: [
{ type: DependencyType.static, source: appName, target: libName },
],
[e2eName]: [
{ type: DependencyType.implicit, source: e2eName, target: libName },
],
},
};
}

View File

@ -0,0 +1,168 @@
import {
logger,
readProjectConfiguration,
stripIndents,
Tree,
updateJson,
updateProjectConfiguration,
} from '@nrwl/devkit';
import { forEachExecutorOptions } from '@nrwl/workspace/src/utilities/executor-options-utils';
import { tsquery } from '@phenomnomnominal/tsquery';
import * as ts from 'typescript';
import { CypressExecutorOptions } from '../../executors/cypress/cypress.impl';
import { installedCypressVersion } from '../../utils/cypress-version';
import { findBuildConfig } from '../../utils/find-target-options';
export async function updateCypressConfigsPresets(tree: Tree) {
if (installedCypressVersion() < 10) {
return;
}
const projectsWithoutDevServerTarget = new Set<string>();
const updateTasks = [];
forEachExecutorOptions<CypressExecutorOptions>(
tree,
'@nrwl/cypress:cypress',
(options, projectName, targetName, configName) => {
if (options.cypressConfig && tree.exists(options.cypressConfig)) {
updatePreset(tree, options, targetName);
}
const projectConfig = readProjectConfiguration(tree, projectName);
const testingType =
options.testingType ||
projectConfig.targets[targetName]?.options?.testingType;
const devServerTarget =
options.devServerTarget ||
projectConfig.targets[targetName]?.options?.devServerTarget;
if (!devServerTarget && testingType === 'component') {
updateTasks.push(
addBuildTargetToConfig(
tree,
projectName,
targetName,
configName
).then((didUpdate) => {
if (!didUpdate) {
projectsWithoutDevServerTarget.add(projectName);
}
})
);
}
}
);
if (updateTasks.length > 0) {
cacheComponentTestTarget(tree);
}
await Promise.all(updateTasks);
if (projectsWithoutDevServerTarget.size > 0) {
logger.warn(
`Unable to find a build target to add to the component testing target in the following projects:`
);
logger.warn(`- ${Array.from(projectsWithoutDevServerTarget).join('\n- ')}`);
logger.warn(stripIndents`
You can manually add the 'devServerTarget' option to the
component testing target to specify the build target to use.
The build configuration should be using @nrwl/web:webpack as the executor.
Usually this is a React app in your workspace.
Component testing will fallback to a default configuration if one isn't provided,
but might require modifications if your projects are more complex.
`);
}
}
function updatePreset(
tree: Tree,
options: CypressExecutorOptions,
targetName: string | undefined
) {
let contents = tsquery.replace(
tree.read(options.cypressConfig, 'utf-8'),
'CallExpression',
(node: ts.CallExpression) => {
// technically someone could have both component and e2e in the same project.
const expression = node.expression.getText();
if (expression === 'nxE2EPreset') {
return 'nxE2EPreset(__filename)';
} else if (expression === 'nxE2EStorybookPreset') {
return 'nxE2EStorybookPreset(__filename)';
} else if (node.expression.getText() === 'nxComponentTestingPreset') {
return targetName && targetName !== 'component-test' // the default
? `nxComponentTestingPreset(__filename, { ctTargetName: '${targetName}' })`
: 'nxComponentTestingPreset(__filename)';
}
return;
}
);
tree.write(options.cypressConfig, contents);
}
async function addBuildTargetToConfig(
tree: Tree,
projectName: string,
targetName: string,
configName?: string
): Promise<boolean> {
const projectWithBuild = await findBuildConfig(tree, {
project: projectName,
validExecutorNames: new Set(['@nrwl/web:webpack']),
});
// didn't find the config so can't update. consumer should collect list of them and display a warning at the end
// no reason to fail since the preset will fallback to a default config so should still keep working.
if (!projectWithBuild?.target || !projectWithBuild?.config) {
return false;
}
const projectConfig = readProjectConfiguration(tree, projectName);
// if using a custom config and the devServerTarget default args
// has a different target, then add it to the custom target config
// otherwise add it to the default options
if (
configName &&
projectWithBuild.target !==
projectConfig.targets[targetName]?.options?.devServerTarget
) {
projectConfig.targets[targetName].configurations[configName] = {
...projectConfig.targets[targetName].configurations[configName],
devServerTarget: projectWithBuild.target,
skipServe: true,
};
} else {
projectConfig.targets[targetName].options = {
...projectConfig.targets[targetName].options,
devServerTarget: projectWithBuild.target,
skipServe: true,
};
}
updateProjectConfiguration(tree, projectName, projectConfig);
return true;
}
function cacheComponentTestTarget(tree: Tree) {
updateJson(tree, 'nx.json', (json) => ({
...json,
tasksRunnerOptions: {
...json.tasksRunnerOptions,
default: {
...json.tasksRunnerOptions?.default,
options: {
...json.tasksRunnerOptions?.default?.options,
cacheableOperations: Array.from(
new Set([
...(json.tasksRunnerOptions?.default?.options
?.cacheableOperations ?? []),
'component-test',
])
),
},
},
},
}));
}
export default updateCypressConfigsPresets;

View File

@ -0,0 +1,174 @@
import {
createProjectGraphAsync,
logger,
parseTargetString,
ProjectGraph,
ProjectGraphDependency,
readProjectConfiguration,
stripIndents,
TargetConfiguration,
Tree,
reverse,
readTargetOptions,
ExecutorContext,
workspaceRoot,
readNxJson,
} from '@nrwl/devkit';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
interface FindTargetOptions {
project: string;
/**
* contains buildable target such as react app or angular app
* <project>:<target>[:<configuration>]
*/
buildTarget?: string;
validExecutorNames: Set<string>;
}
interface FoundTarget {
config: TargetConfiguration;
target: string;
}
export async function findBuildConfig(
tree: Tree,
options: FindTargetOptions
): Promise<FoundTarget> {
// attempt to use the provided target
const graph = await createProjectGraphAsync();
if (options.buildTarget) {
return {
target: options.buildTarget,
config: findInTarget(tree, graph, options),
};
}
// check to see if there is a valid config in the given project
const selfProject = findTargetOptionsInProject(
tree,
graph,
options.project,
options.validExecutorNames
);
if (selfProject) {
return selfProject;
}
// attempt to find any projects with the valid config in the graph that consumes this project
return await findInGraph(tree, graph, options);
}
function findInTarget(
tree: Tree,
graph: ProjectGraph,
options: FindTargetOptions
): TargetConfiguration {
const { project, target, configuration } = parseTargetString(
options.buildTarget
);
const projectConfig = readProjectConfiguration(tree, project);
const foundConfig =
configuration || projectConfig?.targets?.[target]?.defaultConfiguration;
return readTargetOptions(
{ project, target, configuration: foundConfig },
createExecutorContext(
graph,
projectConfig.targets,
project,
target,
foundConfig
)
);
}
async function findInGraph(
tree: Tree,
graph: ProjectGraph,
options: FindTargetOptions
): Promise<FoundTarget> {
const parents = findParentsOfProject(graph, options.project);
const potentialTargets = [];
for (const parent of parents) {
const parentProject = findTargetOptionsInProject(
tree,
graph,
parent.target,
options.validExecutorNames
);
if (parentProject) {
potentialTargets.push(parentProject);
}
}
if (potentialTargets.length > 1) {
logger.warn(stripIndents`Multiple potential targets found for ${options.project}. Found ${potentialTargets.length}.
Using ${potentialTargets[0].target}.
To specify a different target use the --build-target flag.
`);
}
return potentialTargets[0];
}
function findParentsOfProject(
graph: ProjectGraph,
projectName: string
): ProjectGraphDependency[] {
const reversedGraph = reverse(graph);
return reversedGraph.dependencies[projectName]
? Object.values(reversedGraph.dependencies[projectName])
: [];
}
function findTargetOptionsInProject(
tree: Tree,
graph: ProjectGraph,
projectName: string,
includes: Set<string>
): FoundTarget {
const projectConfig = readProjectConfiguration(tree, projectName);
for (const targetName in projectConfig.targets) {
const targetConfig = projectConfig.targets[targetName];
if (includes.has(targetConfig.executor)) {
return {
target: `${projectName}:${targetName}`,
config: readTargetOptions(
{ project: projectName, target: targetName },
createExecutorContext(
graph,
projectConfig.targets,
projectName,
targetName,
null
)
),
};
}
}
}
function createExecutorContext(
graph: ProjectGraph,
targets: Record<string, TargetConfiguration>,
projectName: string,
targetName: string,
configurationName?: string
): ExecutorContext {
const projectConfigs = readProjectsConfigurationFromProjectGraph(graph);
return {
cwd: process.cwd(),
projectGraph: graph,
target: targets[targetName],
targetName,
configurationName,
root: workspaceRoot,
isVerbose: false,
projectName,
workspace: {
...readNxJson(),
...projectConfigs,
},
};
}

View File

@ -1,7 +1,36 @@
import { nxBaseCypressPreset } from '@nrwl/cypress/plugins/cypress-preset'; import { nxBaseCypressPreset } from '@nrwl/cypress/plugins/cypress-preset';
import { getCSSModuleLocalIdent } from '@nrwl/web/src/utils/web.config'; import type { CypressExecutorOptions } from '@nrwl/cypress/src/executors/cypress/cypress.impl';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'; import {
import type { Configuration } from 'webpack'; ExecutorContext,
logger,
parseTargetString,
ProjectConfiguration,
ProjectGraph,
readCachedProjectGraph,
readNxJson,
readTargetOptions,
stripIndents,
Target,
TargetConfiguration,
workspaceRoot,
} from '@nrwl/devkit';
import type { WebWebpackExecutorOptions } from '@nrwl/web/src/executors/webpack/webpack.impl';
import { normalizeWebBuildOptions } from '@nrwl/web/src/utils/normalize';
import { getWebConfig } from '@nrwl/web/src/utils/web.config';
import { mapProjectGraphFiles } from '@nrwl/workspace/src/utils/runtime-lint-utils';
import { lstatSync } from 'fs';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
import { extname, relative } from 'path';
import { buildBaseWebpackConfig } from './webpack-fallback';
export interface ReactComponentTestingOptions {
/**
* the component testing target name.
* this is only when customized away from the default value of `component-test`
* @example 'component-test'
*/
ctTargetName: string;
}
/** /**
* React nx preset for Cypress Component Testing * React nx preset for Cypress Component Testing
@ -18,9 +47,60 @@ import type { Configuration } from 'webpack';
* } * }
* }) * })
* *
* @param pathToConfig will be used to construct the output paths for videos and screenshots * @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) { export function nxComponentTestingPreset(
pathToConfig: string,
options?: ReactComponentTestingOptions
) {
let webpackConfig;
try {
const graph = readCachedProjectGraph();
const { targets: ctTargets, name: ctProjectName } = getConfigByPath(
graph,
pathToConfig
);
const ctTargetName = options?.ctTargetName || 'component-test';
const ctConfigurationName = process.env.NX_CYPRESS_TARGET_CONFIGURATION;
const ctExecutorContext = createExecutorContext(
graph,
ctTargets,
ctProjectName,
ctTargetName,
ctConfigurationName
);
const ctExecutorOptions = readTargetOptions<CypressExecutorOptions>(
{
project: ctProjectName,
target: ctTargetName,
configuration: ctConfigurationName,
},
ctExecutorContext
);
const buildTarget = ctExecutorOptions.devServerTarget;
if (!buildTarget) {
throw new Error(
`Unable to find the 'devServerTarget' executor option in the '${ctTargetName}' target of the '${ctProjectName}' project`
);
}
webpackConfig = buildTargetWebpack(graph, buildTarget, ctProjectName);
} catch (e) {
logger.warn(
stripIndents`Unable to build a webpack config with the project graph.
Falling back to default webpack config.`
);
logger.warn(e);
webpackConfig = buildBaseWebpackConfig({
tsConfigPath: 'tsconfig.cy.json',
compiler: 'babel',
});
}
return { return {
...nxBaseCypressPreset(pathToConfig), ...nxBaseCypressPreset(pathToConfig),
devServer: { devServer: {
@ -28,152 +108,139 @@ export function nxComponentTestingPreset(pathToConfig: string) {
// need to use const to prevent typing to string // need to use const to prevent typing to string
framework: 'react', framework: 'react',
bundler: 'webpack', bundler: 'webpack',
webpackConfig: buildBaseWebpackConfig({ webpackConfig,
tsConfigPath: 'tsconfig.cy.json',
compiler: 'babel',
}),
} as const, } as const,
}; };
} }
// TODO(caleb): use the webpack utils to build the config /**
// can't seem to get css modules to play nice when using it 🤔 * apply the schema.json defaults from the @nrwl/web:webpack executor to the target options
function buildBaseWebpackConfig({ */
tsConfigPath = 'tsconfig.cy.json', function withSchemaDefaults(
compiler = 'babel', target: Target,
}: { context: ExecutorContext
tsConfigPath: string; ): WebWebpackExecutorOptions {
compiler: 'swc' | 'babel'; const options = readTargetOptions<WebWebpackExecutorOptions>(target, context);
}): Configuration {
const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
const config: Configuration = {
target: 'web',
resolve: {
extensions,
plugins: [
new TsconfigPathsPlugin({
configFile: tsConfigPath,
extensions,
}) as never,
],
},
mode: 'development',
devtool: false,
output: {
publicPath: '/',
chunkFilename: '[name].bundle.js',
},
module: {
rules: [
{
test: /\.(bmp|png|jpe?g|gif|webp|avif)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10_000, // 10 kB
},
},
},
CSS_MODULES_LOADER,
],
},
};
if (compiler === 'swc') { options.compiler ??= 'babel';
config.module.rules.push({ options.deleteOutputPath ??= true;
test: /\.([jt])sx?$/, options.vendorChunk ??= true;
loader: require.resolve('swc-loader'), options.commonChunk ??= true;
exclude: /node_modules/, options.runtimeChunk ??= true;
options: { options.sourceMap ??= true;
jsc: { options.assets ??= [];
parser: { options.scripts ??= [];
syntax: 'typescript', options.styles ??= [];
decorators: true, options.budgets ??= [];
tsx: true, options.namedChunks ??= true;
}, options.outputHashing ??= 'none';
transform: { options.extractCss ??= true;
react: { options.memoryLimit ??= 2048;
runtime: 'automatic', options.maxWorkers ??= 2;
}, options.fileReplacements ??= [];
}, options.buildLibsFromSource ??= true;
loose: true, options.generateIndexHtml ??= true;
}, return options;
},
});
}
if (compiler === 'babel') {
config.module.rules.push({
test: /\.(js|jsx|mjs|ts|tsx)$/,
loader: require.resolve('babel-loader'),
options: {
presets: [`@nrwl/react/babel`],
rootMode: 'upward',
babelrc: true,
},
});
}
return config;
} }
const loaderModulesOptions = { function buildTargetWebpack(
modules: { graph: ProjectGraph,
mode: 'local', buildTarget: string,
getLocalIdent: getCSSModuleLocalIdent, componentTestingProjectName: string
}, ) {
importLoaders: 1, const parsed = parseTargetString(buildTarget);
};
const commonLoaders = [ const buildableProjectConfig = graph.nodes[parsed.project]?.data;
{ const ctProjectConfig = graph.nodes[componentTestingProjectName]?.data;
loader: require.resolve('style-loader'),
},
{
loader: require.resolve('css-loader'),
options: loaderModulesOptions,
},
];
const CSS_MODULES_LOADER = { if (!buildableProjectConfig || !ctProjectConfig) {
test: /\.css$|\.scss$|\.sass$|\.less$|\.styl$/, throw new Error(stripIndents`Unable to load project configs from graph.
oneOf: [ Using build target '${buildTarget}'
{ Has build config? ${!!buildableProjectConfig}
test: /\.module\.css$/, Has component config? ${!!ctProjectConfig}
use: commonLoaders, `);
}
const options = normalizeWebBuildOptions(
withSchemaDefaults(
parsed,
createExecutorContext(
graph,
buildableProjectConfig.targets,
parsed.project,
parsed.target,
parsed.target
)
),
workspaceRoot,
buildableProjectConfig.sourceRoot!
);
const isScriptOptimizeOn =
typeof options.optimization === 'boolean'
? options.optimization
: options.optimization && options.optimization.scripts
? options.optimization.scripts
: false;
return getWebConfig(
workspaceRoot,
ctProjectConfig.root,
ctProjectConfig.sourceRoot,
options,
true,
isScriptOptimizeOn,
parsed.configuration
);
}
function getConfigByPath(
graph: ProjectGraph,
configPath: string
): ProjectConfiguration {
const configFileFromWorkspaceRoot = relative(workspaceRoot, configPath);
const normalizedPathFromWorkspaceRoot = lstatSync(configPath).isFile()
? configFileFromWorkspaceRoot.replace(extname(configPath), '')
: configFileFromWorkspaceRoot;
const mappedGraph = mapProjectGraphFiles(graph);
const componentTestingProjectName =
mappedGraph.allFiles[normalizedPathFromWorkspaceRoot];
if (
!componentTestingProjectName ||
!graph.nodes[componentTestingProjectName]?.data
) {
throw new Error(
stripIndents`Unable to find the project configuration that includes ${normalizedPathFromWorkspaceRoot}.
Found project name? ${componentTestingProjectName}.
Graph has data? ${!!graph.nodes[componentTestingProjectName]?.data}`
);
}
// make sure name is set since it can be undefined
graph.nodes[componentTestingProjectName].data.name ??=
componentTestingProjectName;
return graph.nodes[componentTestingProjectName].data;
}
function createExecutorContext(
graph: ProjectGraph,
targets: Record<string, TargetConfiguration>,
projectName: string,
targetName: string,
configurationName: string
): ExecutorContext {
const projectConfigs = readProjectsConfigurationFromProjectGraph(graph);
return {
cwd: process.cwd(),
projectGraph: graph,
target: targets[targetName],
targetName,
configurationName,
root: workspaceRoot,
isVerbose: false,
projectName,
workspace: {
...readNxJson(),
...projectConfigs,
}, },
{ };
test: /\.module\.(scss|sass)$/, }
use: [
...commonLoaders,
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sassOptions: {
fiber: false,
precision: 8,
},
},
},
],
},
{
test: /\.module\.less$/,
use: [
...commonLoaders,
{
loader: require.resolve('less-loader'),
},
],
},
{
test: /\.module\.styl$/,
use: [
...commonLoaders,
{
loader: require.resolve('stylus-loader'),
},
],
},
],
};

View File

@ -0,0 +1,143 @@
import { getCSSModuleLocalIdent } from '@nrwl/web/src/utils/web.config';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack';
export function buildBaseWebpackConfig({
tsConfigPath = 'tsconfig.cy.json',
compiler = 'babel',
}: {
tsConfigPath: string;
compiler: 'swc' | 'babel';
}): Configuration {
const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
const config: Configuration = {
target: 'web',
resolve: {
extensions,
plugins: [
new TsconfigPathsPlugin({
configFile: tsConfigPath,
extensions,
}) as never,
],
},
mode: 'development',
devtool: false,
output: {
publicPath: '/',
chunkFilename: '[name].bundle.js',
},
module: {
rules: [
{
test: /\.(bmp|png|jpe?g|gif|webp|avif)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10_000, // 10 kB
},
},
},
CSS_MODULES_LOADER,
],
},
};
if (compiler === 'swc') {
config.module.rules.push({
test: /\.([jt])sx?$/,
loader: require.resolve('swc-loader'),
exclude: /node_modules/,
options: {
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
loose: true,
},
},
});
}
if (compiler === 'babel') {
config.module.rules.push({
test: /\.(js|jsx|mjs|ts|tsx)$/,
loader: require.resolve('babel-loader'),
options: {
presets: [`@nrwl/react/babel`],
rootMode: 'upward',
babelrc: true,
},
});
}
return config;
}
const loaderModulesOptions = {
modules: {
mode: 'local',
getLocalIdent: getCSSModuleLocalIdent,
},
importLoaders: 1,
};
const commonLoaders = [
{
loader: require.resolve('style-loader'),
},
{
loader: require.resolve('css-loader'),
options: loaderModulesOptions,
},
];
const CSS_MODULES_LOADER = {
test: /\.css$|\.scss$|\.sass$|\.less$|\.styl$/,
oneOf: [
{
test: /\.module\.css$/,
use: commonLoaders,
},
{
test: /\.module\.(scss|sass)$/,
use: [
...commonLoaders,
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sassOptions: {
fiber: false,
precision: 8,
},
},
},
],
},
{
test: /\.module\.less$/,
use: [
...commonLoaders,
{
loader: require.resolve('less-loader'),
},
],
},
{
test: /\.module\.styl$/,
use: [
...commonLoaders,
{
loader: require.resolve('stylus-loader'),
},
],
},
],
};

View File

@ -55,6 +55,28 @@ describe(componentTestGenerator.name, () => {
expect(tree.exists('libs/some-lib/src/lib/some-lib.cy.js')).toBeTruthy(); expect(tree.exists('libs/some-lib/src/lib/some-lib.cy.js')).toBeTruthy();
}); });
it('should not overwrite exising component test', async () => {
mockedAssertMinimumCypressVersion.mockReturnValue();
await libraryGenerator(tree, {
linter: Linter.EsLint,
name: 'some-lib',
skipFormat: true,
skipTsConfig: false,
style: 'scss',
unitTestRunner: 'none',
component: true,
});
tree.write('libs/some-lib/src/lib/some-lib.cy.tsx', 'existing content');
componentTestGenerator(tree, {
project: 'some-lib',
componentPath: 'lib/some-lib.tsx',
});
expect(tree.read('libs/some-lib/src/lib/some-lib.cy.tsx', 'utf-8')).toEqual(
'existing content'
);
});
it('should not throw if path is invalid', async () => { it('should not throw if path is invalid', async () => {
mockedAssertMinimumCypressVersion.mockReturnValue(); mockedAssertMinimumCypressVersion.mockReturnValue();
await libraryGenerator(tree, { await libraryGenerator(tree, {

View File

@ -50,6 +50,11 @@ function generateSpecsForComponents(tree: Tree, filePath: string) {
const componentDir = dirname(filePath); const componentDir = dirname(filePath);
const ext = extname(filePath); const ext = extname(filePath);
const fileName = basename(filePath, ext); const fileName = basename(filePath, ext);
if (tree.exists(joinPathFragments(componentDir, `${fileName}.cy${ext}`))) {
return;
}
const defaultExport = getComponentNode(sourceFile); const defaultExport = getComponentNode(sourceFile);
if (cmpNodes?.length) { if (cmpNodes?.length) {

View File

@ -1,11 +1,25 @@
import { assertMinimumCypressVersion } from '@nrwl/cypress/src/utils/cypress-version'; import { assertMinimumCypressVersion } from '@nrwl/cypress/src/utils/cypress-version';
import { readJson, Tree } from '@nrwl/devkit'; import {
DependencyType,
ProjectGraph,
readJson,
readProjectConfiguration,
Tree,
} from '@nrwl/devkit';
import { createTreeWithEmptyV1Workspace } from '@nrwl/devkit/testing'; import { createTreeWithEmptyV1Workspace } from '@nrwl/devkit/testing';
import { Linter } from '@nrwl/linter'; import { Linter } from '@nrwl/linter';
import componentGenerator from '../component/component'; import { applicationGenerator } from '../application/application';
import libraryGenerator from '../library/library'; import { componentGenerator } from '../component/component';
import { libraryGenerator } from '../library/library';
import { cypressComponentConfigGenerator } from './cypress-component-configuration'; import { cypressComponentConfigGenerator } from './cypress-component-configuration';
let projectGraph: ProjectGraph;
jest.mock('@nrwl/devkit', () => ({
...jest.requireActual<any>('@nrwl/devkit'),
createProjectGraphAsync: jest
.fn()
.mockImplementation(async () => projectGraph),
}));
jest.mock('@nrwl/cypress/src/utils/cypress-version'); jest.mock('@nrwl/cypress/src/utils/cypress-version');
describe('React:CypressComponentTestConfiguration', () => { describe('React:CypressComponentTestConfiguration', () => {
let tree: Tree; let tree: Tree;
@ -15,9 +29,17 @@ describe('React:CypressComponentTestConfiguration', () => {
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyV1Workspace(); tree = createTreeWithEmptyV1Workspace();
}); });
it('should generate cypress component test config', async () => { it('should generate cypress component test config with --build-target', async () => {
mockedAssertCypressVersion.mockReturnValue(); mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, {
e2eTestRunner: 'none',
linter: Linter.EsLint,
skipFormat: true,
style: 'scss',
unitTestRunner: 'none',
name: 'my-app',
});
await libraryGenerator(tree, { await libraryGenerator(tree, {
linter: Linter.EsLint, linter: Linter.EsLint,
name: 'some-lib', name: 'some-lib',
@ -27,17 +49,43 @@ describe('React:CypressComponentTestConfiguration', () => {
unitTestRunner: 'none', unitTestRunner: 'none',
component: true, component: true,
}); });
// --build-target still needs to build the graph in order for readTargetOptions to work
projectGraph = {
nodes: {
'my-app': {
name: 'my-app',
type: 'app',
data: {
...readProjectConfiguration(tree, 'my-app'),
},
},
'some-lib': {
name: 'some-lib',
type: 'lib',
data: {
...readProjectConfiguration(tree, 'some-lib'),
},
},
},
dependencies: {
'my-app': [
{ type: DependencyType.static, source: 'my-app', target: 'some-lib' },
],
},
};
await cypressComponentConfigGenerator(tree, { await cypressComponentConfigGenerator(tree, {
project: 'some-lib', project: 'some-lib',
generateTests: false, generateTests: false,
buildTarget: 'my-app:build',
}); });
const config = tree.read('libs/some-lib/cypress.config.ts', 'utf-8'); const config = tree.read('libs/some-lib/cypress.config.ts', 'utf-8');
expect(config).toContain( expect(config).toContain(
"import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing" "import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing"
); );
expect(config).toContain('component: nxComponentTestingPreset(__dirname),'); expect(config).toContain(
'component: nxComponentTestingPreset(__filename),'
);
const cyTsConfig = readJson(tree, 'libs/some-lib/tsconfig.cy.json'); const cyTsConfig = readJson(tree, 'libs/some-lib/tsconfig.cy.json');
expect(cyTsConfig.include).toEqual([ expect(cyTsConfig.include).toEqual([
@ -63,11 +111,123 @@ describe('React:CypressComponentTestConfiguration', () => {
expect(baseTsConfig.references).toEqual( expect(baseTsConfig.references).toEqual(
expect.arrayContaining([{ path: './tsconfig.cy.json' }]) expect.arrayContaining([{ path: './tsconfig.cy.json' }])
); );
expect(
readProjectConfiguration(tree, 'some-lib').targets['component-test']
).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/some-lib/cypress.config.ts',
devServerTarget: 'my-app:build',
skipServe: true,
testingType: 'component',
},
});
});
it('should generate cypress component test config with project graph', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, {
e2eTestRunner: 'none',
linter: Linter.EsLint,
skipFormat: true,
style: 'scss',
unitTestRunner: 'none',
name: 'my-app',
});
await libraryGenerator(tree, {
linter: Linter.EsLint,
name: 'some-lib',
skipFormat: true,
skipTsConfig: false,
style: 'scss',
unitTestRunner: 'none',
component: true,
});
projectGraph = {
nodes: {
'my-app': {
name: 'my-app',
type: 'app',
data: {
...readProjectConfiguration(tree, 'my-app'),
},
},
'some-lib': {
name: 'some-lib',
type: 'lib',
data: {
...readProjectConfiguration(tree, 'some-lib'),
},
},
},
dependencies: {
'my-app': [
{ type: DependencyType.static, source: 'my-app', target: 'some-lib' },
],
},
};
await cypressComponentConfigGenerator(tree, {
project: 'some-lib',
generateTests: false,
});
const config = tree.read('libs/some-lib/cypress.config.ts', 'utf-8');
expect(config).toContain(
"import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing"
);
expect(config).toContain(
'component: nxComponentTestingPreset(__filename),'
);
const cyTsConfig = readJson(tree, 'libs/some-lib/tsconfig.cy.json');
expect(cyTsConfig.include).toEqual([
'cypress.config.ts',
'**/*.cy.ts',
'**/*.cy.tsx',
'**/*.cy.js',
'**/*.cy.jsx',
'**/*.d.ts',
]);
const libTsConfig = readJson(tree, 'libs/some-lib/tsconfig.lib.json');
expect(libTsConfig.exclude).toEqual(
expect.arrayContaining([
'cypress/**/*',
'cypress.config.ts',
'**/*.cy.ts',
'**/*.cy.js',
'**/*.cy.tsx',
'**/*.cy.jsx',
])
);
const baseTsConfig = readJson(tree, 'libs/some-lib/tsconfig.json');
expect(baseTsConfig.references).toEqual(
expect.arrayContaining([{ path: './tsconfig.cy.json' }])
);
expect(
readProjectConfiguration(tree, 'some-lib').targets['component-test']
).toEqual({
executor: '@nrwl/cypress:cypress',
options: {
cypressConfig: 'libs/some-lib/cypress.config.ts',
devServerTarget: 'my-app:build',
skipServe: true,
testingType: 'component',
},
});
}); });
it('should generate tests for existing tsx components', async () => { it('should generate tests for existing tsx components', async () => {
mockedAssertCypressVersion.mockReturnValue(); mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, {
e2eTestRunner: 'none',
linter: Linter.EsLint,
skipFormat: true,
style: 'scss',
unitTestRunner: 'none',
name: 'my-app',
});
await libraryGenerator(tree, { await libraryGenerator(tree, {
linter: Linter.EsLint, linter: Linter.EsLint,
name: 'some-lib', name: 'some-lib',
@ -86,6 +246,7 @@ describe('React:CypressComponentTestConfiguration', () => {
await cypressComponentConfigGenerator(tree, { await cypressComponentConfigGenerator(tree, {
project: 'some-lib', project: 'some-lib',
generateTests: true, generateTests: true,
buildTarget: 'my-app:build',
}); });
expect(tree.exists('libs/some-lib/src/lib/some-lib.cy.tsx')).toBeTruthy(); expect(tree.exists('libs/some-lib/src/lib/some-lib.cy.tsx')).toBeTruthy();
@ -106,7 +267,14 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
it('should generate tests for existing js components', async () => { it('should generate tests for existing js components', async () => {
mockedAssertCypressVersion.mockReturnValue(); mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, {
e2eTestRunner: 'none',
linter: Linter.EsLint,
skipFormat: true,
style: 'scss',
unitTestRunner: 'none',
name: 'my-app',
});
await libraryGenerator(tree, { await libraryGenerator(tree, {
linter: Linter.EsLint, linter: Linter.EsLint,
name: 'some-lib', name: 'some-lib',
@ -133,6 +301,7 @@ describe('React:CypressComponentTestConfiguration', () => {
await cypressComponentConfigGenerator(tree, { await cypressComponentConfigGenerator(tree, {
project: 'some-lib', project: 'some-lib',
generateTests: true, generateTests: true,
buildTarget: 'my-app:build',
}); });
expect(tree.exists('libs/some-lib/src/lib/some-cmp.cy.js')).toBeTruthy(); expect(tree.exists('libs/some-lib/src/lib/some-cmp.cy.js')).toBeTruthy();

View File

@ -1,21 +1,8 @@
import { cypressComponentProject } from '@nrwl/cypress'; import { cypressComponentProject } from '@nrwl/cypress';
import { import { formatFiles, readProjectConfiguration, Tree } from '@nrwl/devkit';
formatFiles, import { addFiles } from './lib/add-files';
generateFiles, import { updateProjectConfig, updateTsConfig } from './lib/update-configs';
joinPathFragments, import { CypressComponentConfigurationSchema } from './schema.d';
ProjectConfiguration,
readProjectConfiguration,
Tree,
updateJson,
visitNotIgnoredFiles,
} from '@nrwl/devkit';
import * as ts from 'typescript';
import { getComponentNode } from '../../utils/ast-utils';
import componentTestGenerator from '../component-test/component-test';
import { CypressComponentConfigurationSchema } from './schema';
const allowedFileExt = new RegExp(/\.[jt]sx?/g);
const isSpecFile = new RegExp(/(spec|test)\./g);
/** /**
* This is for using cypresses own Component testing, if you want to use test * This is for using cypresses own Component testing, if you want to use test
@ -32,6 +19,7 @@ export async function cypressComponentConfigGenerator(
skipFormat: true, skipFormat: true,
}); });
await updateProjectConfig(tree, options);
addFiles(tree, projectConfig, options); addFiles(tree, projectConfig, options);
updateTsConfig(tree, projectConfig); updateTsConfig(tree, projectConfig);
if (options.skipFormat) { if (options.skipFormat) {
@ -43,110 +31,4 @@ export async function cypressComponentConfigGenerator(
}; };
} }
function addFiles(
tree: Tree,
projectConfig: ProjectConfiguration,
options: CypressComponentConfigurationSchema
) {
const cypressConfigPath = joinPathFragments(
projectConfig.root,
'cypress.config.ts'
);
if (tree.exists(cypressConfigPath)) {
tree.delete(cypressConfigPath);
}
generateFiles(
tree,
joinPathFragments(__dirname, 'files'),
projectConfig.root,
{
tpl: '',
}
);
if (options.generateTests) {
visitNotIgnoredFiles(tree, projectConfig.sourceRoot, (filePath) => {
if (isComponent(tree, filePath)) {
componentTestGenerator(tree, {
project: options.project,
componentPath: filePath,
});
}
});
}
}
function updateTsConfig(tree: Tree, projectConfig: ProjectConfiguration) {
const tsConfigPath = joinPathFragments(
projectConfig.root,
projectConfig.projectType === 'library'
? 'tsconfig.lib.json'
: 'tsconfig.app.json'
);
if (tree.exists(tsConfigPath)) {
updateJson(tree, tsConfigPath, (json) => {
const excluded = new Set([
...(json.exclude || []),
'cypress/**/*',
'cypress.config.ts',
'**/*.cy.ts',
'**/*.cy.js',
'**/*.cy.tsx',
'**/*.cy.jsx',
]);
json.exclude = Array.from(excluded);
return json;
});
}
const projectBaseTsConfig = joinPathFragments(
projectConfig.root,
'tsconfig.json'
);
if (tree.exists(projectBaseTsConfig)) {
updateJson(tree, projectBaseTsConfig, (json) => {
if (json.references) {
const hasCyTsConfig = json.references.some(
(r) => r.path === './tsconfig.cy.json'
);
if (!hasCyTsConfig) {
json.references.push({ path: './tsconfig.cy.json' });
}
} else {
const excluded = new Set([
...(json.exclude || []),
'cypress/**/*',
'cypress.config.ts',
'**/*.cy.ts',
'**/*.cy.js',
'**/*.cy.tsx',
'**/*.cy.jsx',
]);
json.exclude = Array.from(excluded);
}
return json;
});
}
}
function isComponent(tree: Tree, filePath: string): boolean {
if (isSpecFile.test(filePath) || !allowedFileExt.test(filePath)) {
return false;
}
const content = tree.read(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(
filePath,
content,
ts.ScriptTarget.Latest,
true
);
const cmpDeclaration = getComponentNode(sourceFile);
return !!cmpDeclaration;
}
export default cypressComponentConfigGenerator; export default cypressComponentConfigGenerator;

View File

@ -2,5 +2,5 @@ import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing'; import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing';
export default defineConfig({ export default defineConfig({
component: nxComponentTestingPreset(__dirname), component: nxComponentTestingPreset(__filename),
}); });

View File

@ -0,0 +1,65 @@
import {
generateFiles,
joinPathFragments,
ProjectConfiguration,
Tree,
visitNotIgnoredFiles,
} from '@nrwl/devkit';
import * as ts from 'typescript';
import { getComponentNode } from '../../../utils/ast-utils';
import { componentTestGenerator } from '../../component-test/component-test';
import { CypressComponentConfigurationSchema } from '../schema';
const allowedFileExt = new RegExp(/\.[jt]sx?/g);
const isSpecFile = new RegExp(/(spec|test)\./g);
export function addFiles(
tree: Tree,
projectConfig: ProjectConfiguration,
options: CypressComponentConfigurationSchema
) {
const cypressConfigPath = joinPathFragments(
projectConfig.root,
'cypress.config.ts'
);
if (tree.exists(cypressConfigPath)) {
tree.delete(cypressConfigPath);
}
generateFiles(
tree,
joinPathFragments(__dirname, '..', 'files'),
projectConfig.root,
{
tpl: '',
}
);
if (options.generateTests) {
visitNotIgnoredFiles(tree, projectConfig.sourceRoot, (filePath) => {
if (isComponent(tree, filePath)) {
componentTestGenerator(tree, {
project: options.project,
componentPath: filePath,
});
}
});
}
}
function isComponent(tree: Tree, filePath: string): boolean {
if (isSpecFile.test(filePath) || !allowedFileExt.test(filePath)) {
return false;
}
const content = tree.read(filePath, 'utf-8');
const sourceFile = ts.createSourceFile(
filePath,
content,
ts.ScriptTarget.Latest,
true
);
const cmpDeclaration = getComponentNode(sourceFile);
return !!cmpDeclaration;
}

View File

@ -0,0 +1,97 @@
import { findBuildConfig } from '@nrwl/cypress/src/utils/find-target-options';
import {
joinPathFragments,
ProjectConfiguration,
readProjectConfiguration,
Tree,
updateJson,
updateProjectConfiguration,
} from '@nrwl/devkit';
import { CypressComponentConfigurationSchema } from '../schema';
export function updateTsConfig(
tree: Tree,
projectConfig: ProjectConfiguration
) {
const tsConfigPath = joinPathFragments(
projectConfig.root,
projectConfig.projectType === 'library'
? 'tsconfig.lib.json'
: 'tsconfig.app.json'
);
if (tree.exists(tsConfigPath)) {
updateJson(tree, tsConfigPath, (json) => {
const excluded = new Set([
...(json.exclude || []),
'cypress/**/*',
'cypress.config.ts',
'**/*.cy.ts',
'**/*.cy.js',
'**/*.cy.tsx',
'**/*.cy.jsx',
]);
json.exclude = Array.from(excluded);
return json;
});
}
const projectBaseTsConfig = joinPathFragments(
projectConfig.root,
'tsconfig.json'
);
if (tree.exists(projectBaseTsConfig)) {
updateJson(tree, projectBaseTsConfig, (json) => {
if (json.references) {
const hasCyTsConfig = json.references.some(
(r) => r.path === './tsconfig.cy.json'
);
if (!hasCyTsConfig) {
json.references.push({ path: './tsconfig.cy.json' });
}
} else {
const excluded = new Set([
...(json.exclude || []),
'cypress/**/*',
'cypress.config.ts',
'**/*.cy.ts',
'**/*.cy.js',
'**/*.cy.tsx',
'**/*.cy.jsx',
]);
json.exclude = Array.from(excluded);
}
return json;
});
}
}
export async function updateProjectConfig(
tree: Tree,
options: CypressComponentConfigurationSchema
) {
const found = await findBuildConfig(tree, {
project: options.project,
buildTarget: options.buildTarget,
validExecutorNames: new Set<string>(['@nrwl/web:webpack']),
});
assetValidConfig(found.config);
const projectConfig = readProjectConfiguration(tree, options.project);
projectConfig.targets['component-test'].options = {
...projectConfig.targets['component-test'].options,
devServerTarget: found.target,
skipServe: true,
};
updateProjectConfiguration(tree, options.project, projectConfig);
}
function assetValidConfig(config: unknown) {
if (!config) {
throw new Error(
'Unable to find a valid build configuration. Try passing in a target for a React app. --build-target=<project>:<target>[:<configuration>]'
);
}
}

View File

@ -2,4 +2,5 @@ export interface CypressComponentConfigurationSchema {
project: string; project: string;
generateTests: boolean; generateTests: boolean;
skipFormat?: boolean; skipFormat?: boolean;
buildTarget?: string;
} }

View File

@ -1,5 +1,5 @@
{ {
"$schema": "http://json-schema.org/schema", "$schema": "https://json-schema.org/schema",
"cli": "nx", "cli": "nx",
"$id": "NxReactCypressComponentTestConfiguration", "$id": "NxReactCypressComponentTestConfiguration",
"title": "Add Cypress component testing", "title": "Add Cypress component testing",
@ -24,6 +24,11 @@
}, },
"x-prompt": "What project should we add Cypress component testing to?" "x-prompt": "What project should we add Cypress component testing to?"
}, },
"buildTarget": {
"type": "string",
"description": "A build target used to configure Cypress component testing in the format of `project:target[:configuration]`. The build target should be from a React app. If not provided we will try to infer it from your projects usage.",
"pattern": "^[^:\\s]+:[^:\\s]+(:\\S+)?$"
},
"generateTests": { "generateTests": {
"type": "boolean", "type": "boolean",
"description": "Generate default component tests for existing components in the project", "description": "Generate default component tests for existing components in the project",