From 1a235d723685d281cb5d0c6246aebf32fdb2c970 Mon Sep 17 00:00:00 2001 From: Nicholas Cunningham Date: Wed, 26 Mar 2025 10:44:28 -0600 Subject: [PATCH] fix(react): react-router should work with jest out of the box (#30487) Jest should be compatible with react-router out of the box. ## Current Behavior Currently, there are two issues when using `jest` with react-router out of the box 1. Test files are not included from `tsconfig` 2. While running the test `jsdom` is missing Node's `TextEncoder` and `TextDecoder` so compilation fails. ## Expected Behavior Running a test should work without issues when you create a react-router app with Jest. ## Related Issue(s) Fixes #30387 --- e2e/react/src/react-router.test.ts | 147 ++++++++++++------ .../files/common/src/test-setup.ts__tmpl__ | 8 +- .../src/generators/configuration/schema.d.ts | 7 +- .../application/application.spec.ts | 50 ++++++ .../src/generators/application/application.ts | 3 +- .../common/app/app-nav.tsx__tmpl__ | 1 - .../generators/application/lib/add-jest.ts | 37 ++++- 7 files changed, 201 insertions(+), 52 deletions(-) diff --git a/e2e/react/src/react-router.test.ts b/e2e/react/src/react-router.test.ts index 67c2f5edf3..53d2ab19d5 100644 --- a/e2e/react/src/react-router.test.ts +++ b/e2e/react/src/react-router.test.ts @@ -9,60 +9,117 @@ import { } from '@nx/e2e/utils'; describe('React Router Applications', () => { - beforeAll(() => { - newProject({ packages: ['@nx/react'] }); - ensureCypressInstallation(); - }); - - afterAll(() => cleanupProject()); - - it('should generate a react-router application', async () => { + describe('TS paths', () => { const appName = uniq('app'); - runCLI( - `generate @nx/react:app ${appName} --use-react-router --routing --no-interactive` - ); + beforeAll(() => { + newProject({ packages: ['@nx/react'] }); + ensureCypressInstallation(); + runCLI( + `generate @nx/react:app ${appName} --use-react-router --routing --linter=eslint --unit-test-runner=vitest --no-interactive` + ); + }); - const packageJson = JSON.parse(readFile('package.json')); - expect(packageJson.dependencies['react-router']).toBeDefined(); - expect(packageJson.dependencies['@react-router/node']).toBeDefined(); - expect(packageJson.dependencies['@react-router/serve']).toBeDefined(); - expect(packageJson.dependencies['isbot']).toBeDefined(); + afterAll(() => cleanupProject()); - checkFilesExist(`${appName}/app/app.tsx`); - checkFilesExist(`${appName}/app/entry.client.tsx`); - checkFilesExist(`${appName}/app/entry.server.tsx`); - checkFilesExist(`${appName}/app/routes.tsx`); - checkFilesExist(`${appName}/react-router.config.ts`); - checkFilesExist(`${appName}/vite.config.ts`); + it('should generate a react-router application', async () => { + const packageJson = JSON.parse(readFile('package.json')); + expect(packageJson.dependencies['react-router']).toBeDefined(); + expect(packageJson.dependencies['@react-router/node']).toBeDefined(); + expect(packageJson.dependencies['@react-router/serve']).toBeDefined(); + expect(packageJson.dependencies['isbot']).toBeDefined(); + + checkFilesExist(`${appName}/app/app.tsx`); + checkFilesExist(`${appName}/app/entry.client.tsx`); + checkFilesExist(`${appName}/app/entry.server.tsx`); + checkFilesExist(`${appName}/app/routes.tsx`); + checkFilesExist(`${appName}/react-router.config.ts`); + checkFilesExist(`${appName}/vite.config.ts`); + }); + + it('should be able to build a react-router application', async () => { + const buildResult = runCLI(`build ${appName}`); + expect(buildResult).toContain('Successfully ran target build'); + }); + + it('should be able to lint a react-router application', async () => { + const lintResult = runCLI(`lint ${appName}`); + expect(lintResult).toContain('Successfully ran target lint'); + }); + + it('should be able to test and typecheck a react-router application', async () => { + const typeCheckResult = runCLI(`typecheck ${appName}`); + expect(typeCheckResult).toContain('Successfully ran target typecheck'); + }); + + it('should be able to test and typecheck a react-router application with jest', async () => { + const jestApp = uniq('jestApp'); + runCLI( + `generate @nx/react:app ${jestApp} --use-react-router --routing --unit-test-runner=jest --no-interactive` + ); + + const testResult = runCLI(`test ${jestApp}`); + expect(testResult).toContain('Successfully ran target test'); + + const typeCheckResult = runCLI(`typecheck ${jestApp}`); + expect(typeCheckResult).toContain('Successfully ran target typecheck'); + }); }); - - it('should be able to build a react-router application', async () => { + describe('TS Solution', () => { const appName = uniq('app'); - runCLI( - `generate @nx/react:app ${appName} --use-react-router --routing --no-interactive` - ); + beforeAll(() => { + newProject({ preset: 'ts', packages: ['@nx/react'] }); + ensureCypressInstallation(); + runCLI( + `generate @nx/react:app ${appName} --use-react-router --routing --linter=eslint --unit-test-runner=vitest --no-interactive` + ); + }); - const buildResult = runCLI(`build ${appName}`); - expect(buildResult).toContain('Successfully ran target build'); - }); + afterAll(() => cleanupProject()); - it('should be able to lint a react-router application', async () => { - const appName = uniq('app'); - runCLI( - `generate @nx/react:app ${appName} --use-react-router --routing --linter=eslint --no-interactive` - ); + it('should generate a react-router application', async () => { + const packageJson = JSON.parse(readFile('package.json')); + expect(packageJson.dependencies['react-router']).toBeDefined(); + expect(packageJson.dependencies['@react-router/node']).toBeDefined(); + expect(packageJson.dependencies['@react-router/serve']).toBeDefined(); + expect(packageJson.dependencies['isbot']).toBeDefined(); - const buildResult = runCLI(`lint ${appName}`); - expect(buildResult).toContain('Successfully ran target lint'); - }); + checkFilesExist(`${appName}/app/app.tsx`); + checkFilesExist(`${appName}/app/entry.client.tsx`); + checkFilesExist(`${appName}/app/entry.server.tsx`); + checkFilesExist(`${appName}/app/routes.tsx`); + checkFilesExist(`${appName}/react-router.config.ts`); + checkFilesExist(`${appName}/vite.config.ts`); + }); - it('should be able to test a react-router application', async () => { - const appName = uniq('app'); - runCLI( - `generate @nx/react:app ${appName} --use-react-router --routing --unit-test-runner=vitest --no-interactive` - ); + it('should be able to build a react-router application', async () => { + const buildResult = runCLI(`build ${appName}`); + expect(buildResult).toContain('Successfully ran target build'); + }); - const buildResult = runCLI(`test ${appName}`); - expect(buildResult).toContain('Successfully ran target test'); + it('should be able to lint a react-router application', async () => { + const lintResult = runCLI(`lint ${appName}`); + expect(lintResult).toContain('Successfully ran target lint'); + }); + + it('should be able to test and typecheck a react-router application', async () => { + const testResult = runCLI(`test ${appName}`); + expect(testResult).toContain('Successfully ran target test'); + + const typeCheckResult = runCLI(`typecheck ${appName}`); + expect(typeCheckResult).toContain('Successfully ran target typecheck'); + }); + + it('should be able to test and typecheck a react-router application with jest', async () => { + const jestApp = uniq('jestApp'); + runCLI( + `generate @nx/react:app ${jestApp} --use-react-router --routing --unit-test-runner=jest --no-interactive` + ); + + const testResult = runCLI(`test ${jestApp}`); + expect(testResult).toContain('Successfully ran target test'); + + const typeCheckResult = runCLI(`typecheck ${jestApp}`); + expect(typeCheckResult).toContain('Successfully ran target typecheck'); + }); }); }); diff --git a/packages/jest/src/generators/configuration/files/common/src/test-setup.ts__tmpl__ b/packages/jest/src/generators/configuration/files/common/src/test-setup.ts__tmpl__ index 46cf3ff161..34b7773682 100644 --- a/packages/jest/src/generators/configuration/files/common/src/test-setup.ts__tmpl__ +++ b/packages/jest/src/generators/configuration/files/common/src/test-setup.ts__tmpl__ @@ -1 +1,7 @@ -<% if(setupFile === 'react-native') { %>import '@testing-library/jest-native/extend-expect';<% } %> \ No newline at end of file +<% if(setupFile === 'react-native') { %>import '@testing-library/jest-native/extend-expect';<% } %> +<%_ if(setupFile === 'react-router') { _%> +import { TextEncoder, TextDecoder as NodeTextDecoder } from "util"; + +global.TextEncoder = TextEncoder; +global.TextDecoder = NodeTextDecoder as typeof TextDecoder; // necessary because there is a mismatch between ts type and node type +<%_ } _%> \ No newline at end of file diff --git a/packages/jest/src/generators/configuration/schema.d.ts b/packages/jest/src/generators/configuration/schema.d.ts index e8daf83f66..363d5a4b74 100644 --- a/packages/jest/src/generators/configuration/schema.d.ts +++ b/packages/jest/src/generators/configuration/schema.d.ts @@ -6,7 +6,12 @@ export interface JestProjectSchema { * @deprecated use setupFile instead */ skipSetupFile?: boolean; - setupFile?: 'angular' | 'web-components' | 'react-native' | 'none'; + setupFile?: + | 'angular' + | 'web-components' + | 'react-native' + | 'react-router' + | 'none'; skipSerializers?: boolean; testEnvironment?: 'node' | 'jsdom' | 'none'; /** diff --git a/packages/react/src/generators/application/application.spec.ts b/packages/react/src/generators/application/application.spec.ts index 06327f198a..45d3cfa138 100644 --- a/packages/react/src/generators/application/application.spec.ts +++ b/packages/react/src/generators/application/application.spec.ts @@ -1161,6 +1161,56 @@ describe('app', () => { expect(packageJson.dependencies['react-router']).toBeDefined(); expect(packageJson.devDependencies['@react-router/dev']).toBeDefined(); }); + + it('should be configured to work with jest', async () => { + await applicationGenerator(appTree, { + ...schema, + skipFormat: false, + useReactRouter: true, + routing: true, + bundler: 'vite', + unitTestRunner: 'jest', + }); + + const jestConfig = appTree.read('my-app/jest.config.ts').toString(); + expect(jestConfig).toContain('@nx/react/plugins/jest'); + expect(appTree.read('my-app/tsconfig.spec.json').toString()) + .toMatchInlineSnapshot(` + "{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "jsx": "react-jsx", + "types": [ + "jest", + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts", + "test/**/*.spec.tsx", + "test/**/*.spec.ts", + "test/**/*.test.tsx", + "test/**/*.test.ts" + ] + } + " + `); + }); }); describe('--directory="." (--root-project)', () => { diff --git a/packages/react/src/generators/application/application.ts b/packages/react/src/generators/application/application.ts index 697d701646..48ed62dd00 100644 --- a/packages/react/src/generators/application/application.ts +++ b/packages/react/src/generators/application/application.ts @@ -235,7 +235,8 @@ export async function applicationGeneratorInternal( }, options.linter === 'eslint' ? ['eslint.config.js', 'eslint.config.cjs', 'eslint.config.mjs'] - : undefined + : undefined, + options.useReactRouter ? 'app' : 'src' ); sortPackageJsonFields(tree, options.appProjectRoot); diff --git a/packages/react/src/generators/application/files/react-router-ssr/common/app/app-nav.tsx__tmpl__ b/packages/react/src/generators/application/files/react-router-ssr/common/app/app-nav.tsx__tmpl__ index 79a0ca213a..11ac8f5edd 100644 --- a/packages/react/src/generators/application/files/react-router-ssr/common/app/app-nav.tsx__tmpl__ +++ b/packages/react/src/generators/application/files/react-router-ssr/common/app/app-nav.tsx__tmpl__ @@ -1,4 +1,3 @@ -import * as React from "react"; import { NavLink } from "react-router"; export function AppNav() { diff --git a/packages/react/src/generators/application/lib/add-jest.ts b/packages/react/src/generators/application/lib/add-jest.ts index 23f3af529f..8df7d13c06 100644 --- a/packages/react/src/generators/application/lib/add-jest.ts +++ b/packages/react/src/generators/application/lib/add-jest.ts @@ -1,6 +1,7 @@ -import { ensurePackage, GeneratorCallback, Tree } from '@nx/devkit'; +import { ensurePackage, GeneratorCallback, Tree, updateJson } from '@nx/devkit'; import { NormalizedSchema } from '../schema'; import { nxVersion } from '../../../utils/versions'; +import { join } from 'node:path'; export async function addJest( host: Tree, @@ -15,14 +16,44 @@ export async function addJest( nxVersion ); - return await configurationGenerator(host, { + await configurationGenerator(host, { ...options, project: options.projectName, supportTsx: true, skipSerializers: true, - setupFile: 'none', + setupFile: options.useReactRouter ? 'react-router' : 'none', compiler: options.compiler, skipFormat: true, runtimeTsconfigFileName: 'tsconfig.app.json', }); + + if (options.useReactRouter) { + updateJson( + host, + join(options.appProjectRoot, 'tsconfig.spec.json'), + (json) => { + json.include = json.include ?? []; + const reactRouterTestGlob = options.js + ? [ + 'test/**/*.spec.jsx', + 'test/**/*.spec.js', + 'test/**/*.test.jsx', + 'test/**/*.test.js', + ] + : [ + 'test/**/*.spec.tsx', + 'test/**/*.spec.ts', + 'test/**/*.test.tsx', + 'test/**/*.test.ts', + ]; + return { + ...json, + include: Array.from( + new Set([...json.include, ...reactRouterTestGlob]) + ), + }; + } + ); + } + return () => {}; }