diff --git a/e2e/nx-forge-e2e/src/application.generator.spec.ts b/e2e/nx-forge-e2e/src/application.generator.spec.ts index e04b04d..cfc4cd5 100644 --- a/e2e/nx-forge-e2e/src/application.generator.spec.ts +++ b/e2e/nx-forge-e2e/src/application.generator.spec.ts @@ -37,6 +37,16 @@ const describeDirectoryTree = (directory: string, depth = 0): string => { .join('\n'); }; +const parseJsonOutput = (output: string) => { + const jsonStart = output.indexOf('{'); + + if (jsonStart === -1) { + throw new Error(`Expected JSON output, received:\n${output}`); + } + + return JSON.parse(output.slice(jsonStart)); +}; + const expectWebpackBuildOutput = async ( workspaceDirectory: string, appName: string @@ -105,7 +115,7 @@ const configureWebpackTaskInference = ( const enableWebpackTaskInference = async (workspaceDirectory: string) => { configureWebpackTaskInference(workspaceDirectory, true); await runNxCommandAsync( - 'generate @nx/webpack:init --addPlugin=true --interactive=false', + 'generate @nx/webpack:init --addPlugin=true --skipPackageJson=true --interactive=false', { cwd: workspaceDirectory, } @@ -148,6 +158,41 @@ describe('Forge application generator', () => { expect( existsSync(join(workspaceDirectory, 'apps', appName, 'src', 'index.ts')) ).toBe(true); + + const project = JSON.parse( + readFileSync( + join(workspaceDirectory, 'apps', appName, 'project.json'), + 'utf8' + ) + ); + expect(project.targets?.lint).toBeUndefined(); + + const resolvedProject = parseJsonOutput( + ( + await runNxCommandAsync(`show project ${appName} --json`, { + cwd: workspaceDirectory, + }) + ).stdout + ); + expect(resolvedProject.targets.lint).toMatchObject({ + cache: true, + executor: 'nx:run-commands', + outputs: ['{options.outputFile}'], + options: { + command: 'eslint .', + cwd: `apps/${appName}`, + }, + }); + + await runNxCommandAsync( + `run ${appName}:lint --output-file=lint-results.json --format=json`, + { + cwd: workspaceDirectory, + } + ); + expect( + existsSync(join(workspaceDirectory, 'apps', appName, 'lint-results.json')) + ).toBe(true); }); describe('--directory', () => { @@ -221,7 +266,7 @@ describe('Forge application generator', () => { const appName = await generateForgeApp({ cwd: workspaceDirectory, directory: 'apps', - options: '--bundler=webpack', + options: '--bundler=webpack --linter=none --unitTestRunner=none', }); expect( readFileSync( diff --git a/e2e/nx-forge-e2e/src/utils/generate-forge-app.ts b/e2e/nx-forge-e2e/src/utils/generate-forge-app.ts index 6d589c5..51412f0 100644 --- a/e2e/nx-forge-e2e/src/utils/generate-forge-app.ts +++ b/e2e/nx-forge-e2e/src/utils/generate-forge-app.ts @@ -1,4 +1,4 @@ -import { runNxCommandAsync } from './async-commands'; +import { formatCommandResult, runNxCommandAsync } from './async-commands'; const uniqueAppName = () => `nx-forge-test-app-${Date.now()}-${Math.floor(Math.random() * 100000)}`; @@ -22,11 +22,23 @@ export const generateForgeApp = async ({ options?: string; }): Promise => { const appName = uniqueAppName(); - await runNxCommandAsync( - `generate @toolsplus/nx-forge:application ${directory}/${appName} ${ - options ?? '' - }`, - { cwd } - ); + const command = `generate @toolsplus/nx-forge:application ${directory}/${appName} ${ + options ?? '' + }`; + const result = await runNxCommandAsync(command, { cwd }); + + if ( + `${result.stdout}\n${result.stderr}`.includes( + 'deprecated `@nx/eslint:lint` executor' + ) + ) { + throw new Error( + [ + 'Generated application used the deprecated ESLint executor.', + formatCommandResult(`nx ${command}`, result), + ].join('\n\n') + ); + } + return appName; }; diff --git a/e2e/nx-forge-e2e/src/utils/test-workspace.ts b/e2e/nx-forge-e2e/src/utils/test-workspace.ts index fa30c5f..4668c2e 100644 --- a/e2e/nx-forge-e2e/src/utils/test-workspace.ts +++ b/e2e/nx-forge-e2e/src/utils/test-workspace.ts @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { workspaceRoot } from '@nx/devkit'; +import { NX_VERSION, workspaceRoot } from '@nx/devkit'; const TEST_WORKSPACES_ROOT = join(workspaceRoot, 'tmp'); @@ -41,7 +41,7 @@ export const createTestWorkspace = ( }); runCommand( - `npx -y create-nx-workspace@latest ${workspaceName} --preset=apps --nxCloud=skip --packageManager=npm --no-interactive`, + `npx -y create-nx-workspace@${NX_VERSION} ${workspaceName} --preset=apps --nxCloud=skip --packageManager=npm --no-interactive`, TEST_WORKSPACES_ROOT ); diff --git a/nx.json b/nx.json index 896809b..6a8c526 100644 --- a/nx.json +++ b/nx.json @@ -6,21 +6,12 @@ "$schema": "./node_modules/nx/schemas/nx-schema.json", "targetDefaults": { "build": { - "dependsOn": [ - "^build" - ], - "inputs": [ - "production", - "^production" - ], + "dependsOn": ["^build"], + "inputs": ["production", "^production"], "cache": true }, "@nx/jest:jest": { - "inputs": [ - "default", - "^production", - "{workspaceRoot}/jest.preset.js" - ], + "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], "cache": true, "options": { "passWithNoTests": true @@ -32,32 +23,27 @@ } } }, - "@nx/eslint:lint": { + "lint": { "inputs": [ "default", "^default", "{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/eslint.config.mjs", - "{workspaceRoot}/tools/eslint-rules/**/*" + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": ["eslint"] + } ], "cache": true }, "@nx/js:tsc": { "cache": true, - "dependsOn": [ - "^build" - ], - "inputs": [ - "production", - "^production" - ] + "dependsOn": ["^build"], + "inputs": ["production", "^production"] } }, "namedInputs": { - "default": [ - "{projectRoot}/**/*", - "sharedGlobals" - ], + "default": ["{projectRoot}/**/*", "sharedGlobals"], "sharedGlobals": [], "production": [ "default", @@ -70,5 +56,14 @@ ] }, "defaultBase": "main", - "analytics": false + "analytics": false, + "plugins": [ + { + "plugin": "@nx/eslint/plugin", + "options": { + "targetName": "lint" + }, + "include": ["packages/nx-forge/**/*", "tools/docs/**/*"] + } + ] } diff --git a/packages/nx-forge/project.json b/packages/nx-forge/project.json index 976ed35..5333ef0 100644 --- a/packages/nx-forge/project.json +++ b/packages/nx-forge/project.json @@ -55,10 +55,6 @@ ] } }, - "lint": { - "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"] - }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/packages/nx-forge"], diff --git a/packages/nx-forge/src/generators/application/generator.inference.spec.ts b/packages/nx-forge/src/generators/application/generator.inference.spec.ts new file mode 100644 index 0000000..9cd2b6d --- /dev/null +++ b/packages/nx-forge/src/generators/application/generator.inference.spec.ts @@ -0,0 +1,44 @@ +import { readNxJson, readProjectConfiguration, Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import './test-utils/mock-plugin-inference.spec-helper'; + +import { applicationGenerator } from './generator'; + +describe('application generator ESLint inference', () => { + let tree: Tree; + let nxAddPlugins: string | undefined; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + nxAddPlugins = process.env.NX_ADD_PLUGINS; + delete process.env.NX_ADD_PLUGINS; + }); + + afterEach(() => { + if (nxAddPlugins === undefined) { + delete process.env.NX_ADD_PLUGINS; + } else { + process.env.NX_ADD_PLUGINS = nxAddPlugins; + } + }); + + it('uses the inferred lint target by default', async () => { + await applicationGenerator(tree, { + directory: 'my-forge-app', + skipFormat: true, + }); + + const nxJson = readNxJson(tree); + expect(nxJson.plugins).toContainEqual({ + plugin: '@nx/eslint/plugin', + options: { targetName: 'lint' }, + }); + + const project = readProjectConfiguration(tree, 'my-forge-app'); + expect(project.targets?.lint).toBeUndefined(); + expect(project.targets?.build?.executor).toBe('@nx/webpack:webpack'); + expect(project.targets?.test?.executor).toBe('@nx/jest:jest'); + expect(tree.exists('my-forge-app/eslint.config.mjs')).toBe(true); + }); +}); diff --git a/packages/nx-forge/src/generators/application/generator.legacy.spec.ts b/packages/nx-forge/src/generators/application/generator.legacy.spec.ts index 65595ab..a3770d1 100644 --- a/packages/nx-forge/src/generators/application/generator.legacy.spec.ts +++ b/packages/nx-forge/src/generators/application/generator.legacy.spec.ts @@ -1,4 +1,5 @@ import { + logger, readNxJson, readProjectConfiguration, Tree, @@ -6,6 +7,7 @@ import { } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import './test-utils/mock-plugin-inference.spec-helper'; import { applicationGenerator } from './generator'; describe('application generator (legacy)', () => { @@ -21,6 +23,8 @@ describe('application generator (legacy)', () => { await applicationGenerator(tree, { directory: 'my-forge-app', bundler: 'webpack', + linter: 'none', + unitTestRunner: 'none', addPlugin: false, }); const project = readProjectConfiguration(tree, 'my-forge-app'); @@ -55,4 +59,25 @@ describe('application generator (legacy)', () => { expect(webpackConfig).toContain(`composePlugins`); expect(webpackConfig).toContain(`target: 'node'`); }); + + it('supports an explicit lint executor with a deprecation warning', async () => { + const warn = jest.spyOn(logger, 'warn').mockImplementation(); + + await applicationGenerator(tree, { + directory: 'my-forge-app', + bundler: 'esbuild', + unitTestRunner: 'none', + addPlugin: false, + skipFormat: true, + }); + + const project = readProjectConfiguration(tree, 'my-forge-app'); + expect(project.targets?.lint).toEqual({ executor: '@nx/eslint:lint' }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Generating a target that uses the deprecated `@nx/eslint:lint` executor.' + ) + ); + }); }); diff --git a/packages/nx-forge/src/generators/application/generator.spec.ts b/packages/nx-forge/src/generators/application/generator.spec.ts index d13773a..51d6b73 100644 --- a/packages/nx-forge/src/generators/application/generator.spec.ts +++ b/packages/nx-forge/src/generators/application/generator.spec.ts @@ -6,68 +6,7 @@ import { readProjectConfiguration, Tree, } from '@nx/devkit'; - -/* - * Force lintProjectGenerator and configurationGenerator to run with addPlugin: false - * so these generator.spec.ts snapshots assert explicit generated targets instead of - * depending on Nx plugin inference. This keeps the outputs deterministic in tests. - * - * What we verified locally: - * - applicationGenerator() passes addPlugin through to lintProjectGenerator() and - * configurationGenerator() in generator.ts. - * - These tests use createTreeWithEmptyWorkspace(), so the Tree root is '/virtual'. - * - With addPlugin: true, current Nx does not stay inside the in-memory Tree. During a - * Jest test run, multiGlobWithWorkspaceContext('/virtual', ...) unexpectedly returns - * real workspace files such as e2e/nx-forge-e2e/project.json and - * e2e/nx-forge-e2e/jest.config.js. - * - @nx/jest/plugin then calls readdirSync(join('/virtual', projectRoot)), which crashes - * with ENOENT for '/virtual/e2e/nx-forge-e2e'. - * - Strangely, this bug only appeared after switching to pnpm. It is not clear why this - * bug was not surfaced under npm. - * - * Nx upstream usually takes a different approach: generator specs often keep addPlugin: true - * and import a private test helper that mocks the project graph, for example: - * - https://github.com/nrwl/nx/blob/master/packages/js/src/generators/library/library.spec.ts - * - https://github.com/nrwl/nx/blob/master/packages/jest/src/generators/configuration/configuration.spec.ts - * - https://github.com/nrwl/nx/blob/master/packages/eslint/src/generators/lint-project/lint-project.spec.ts - * - https://github.com/nrwl/nx/blob/master/packages/nx/src/internal-testing-utils/mock-project-graph.ts - * - * We tried that upstream-style alternative here, but it does not work cleanly in this repo. - * The helper is not published in the installed Nx package, and mirroring its - * createProjectGraphAsync mock locally still left the Jest test path above failing. - * - * Similar Nx issues: - * - https://github.com/nrwl/nx/issues/32588 - * - https://github.com/nrwl/nx/issues/29708 - * - https://github.com/nrwl/nx/issues/34474 - */ -jest.mock('@nx/eslint', () => { - const actual = jest.requireActual('@nx/eslint'); - - return { - ...actual, - lintProjectGenerator: jest.fn((tree, options) => - actual.lintProjectGenerator(tree, { - ...options, - addPlugin: false, - }) - ), - }; -}); - -jest.mock('@nx/jest', () => { - const actual = jest.requireActual('@nx/jest'); - - return { - ...actual, - configurationGenerator: jest.fn((tree, options) => - actual.configurationGenerator(tree, { - ...options, - addPlugin: false, - }) - ), - }; -}); +import './test-utils/mock-plugin-inference.spec-helper'; import { applicationGenerator } from './generator'; import { ApplicationGeneratorOptions } from './schema'; @@ -102,20 +41,7 @@ describe('application generator', () => { "root": "my-forge-app", "sourceRoot": "my-forge-app/src", "tags": [], - "targets": { - "lint": { - "executor": "@nx/eslint:lint", - }, - "test": { - "executor": "@nx/jest:jest", - "options": { - "jestConfig": "my-forge-app/jest.config.cts", - }, - "outputs": [ - "{workspaceRoot}/coverage/{projectRoot}", - ], - }, - }, + "targets": {}, } `); }); @@ -176,18 +102,6 @@ describe('application generator', () => { "{options.outputPath}", ], }, - "lint": { - "executor": "@nx/eslint:lint", - }, - "test": { - "executor": "@nx/jest:jest", - "options": { - "jestConfig": "my-forge-app/jest.config.cts", - }, - "outputs": [ - "{workspaceRoot}/coverage/{projectRoot}", - ], - }, }, } `); @@ -297,20 +211,7 @@ describe('application generator', () => { "root": "my-dir/my-forge-app", "sourceRoot": "my-dir/my-forge-app/src", "tags": [], - "targets": { - "lint": { - "executor": "@nx/eslint:lint", - }, - "test": { - "executor": "@nx/jest:jest", - "options": { - "jestConfig": "my-dir/my-forge-app/jest.config.cts", - }, - "outputs": [ - "{workspaceRoot}/coverage/{projectRoot}", - ], - }, - }, + "targets": {}, } `); }); @@ -372,18 +273,6 @@ describe('application generator', () => { "{options.outputPath}", ], }, - "lint": { - "executor": "@nx/eslint:lint", - }, - "test": { - "executor": "@nx/jest:jest", - "options": { - "jestConfig": "my-dir/my-forge-app/jest.config.cts", - }, - "outputs": [ - "{workspaceRoot}/coverage/{projectRoot}", - ], - }, }, } `); diff --git a/packages/nx-forge/src/generators/application/generator.ts b/packages/nx-forge/src/generators/application/generator.ts index d06faec..79ea6c8 100644 --- a/packages/nx-forge/src/generators/application/generator.ts +++ b/packages/nx-forge/src/generators/application/generator.ts @@ -17,6 +17,7 @@ import { ApplicationGeneratorOptions, NormalizedOptions } from './schema'; import { addProject, addAppFiles, normalizeOptions } from './lib'; import { addProjectDependencies } from './lib/add-project-dependencies'; import { logShowProjectCommand } from '@nx/devkit/internal'; +import { hasWebpackPlugin } from '../../utils/has-webpack-plugin'; function updateTsConfigOptions(tree: Tree, options: NormalizedOptions) { updateJson(tree, `${options.appProjectRoot}/tsconfig.json`, (json) => { @@ -47,17 +48,26 @@ export async function applicationGenerator( tree: Tree, schema: ApplicationGeneratorOptions ) { - return await applicationGeneratorInternal(tree, { - addPlugin: false, - ...schema, - }); + return await applicationGeneratorInternal(tree, schema); } export async function applicationGeneratorInternal( tree: Tree, rawOptions: ApplicationGeneratorOptions ): Promise { + // Preserve explicit Jest targets by default. Webpack remains explicit in a + // fresh workspace, but must match a plugin the workspace already configured. + const addJestPlugin = rawOptions.addPlugin ?? false; const options = await normalizeOptions(tree, rawOptions); + const addWebpackPlugin = + rawOptions.addPlugin ?? + (options.addPlugin && + options.bundler === 'webpack' && + hasWebpackPlugin(tree)); + const projectOptions = { + ...options, + addPlugin: addWebpackPlugin, + }; const tasks: GeneratorCallback[] = []; @@ -85,13 +95,13 @@ export async function applicationGeneratorInternal( const webpackInitTask = await webpackInitGenerator(tree, { skipPackageJson: options.skipPackageJson, skipFormat: true, - addPlugin: options.addPlugin, + addPlugin: addWebpackPlugin, }); tasks.push(webpackInitTask); } - addAppFiles(tree, options); - addProject(tree, options); + addAppFiles(tree, projectOptions); + addProject(tree, projectOptions); updateTsConfigOptions(tree, options); @@ -114,6 +124,7 @@ export async function applicationGeneratorInternal( if (options.unitTestRunner === 'jest') { const jestTask = await configurationGenerator(tree, { ...options, + addPlugin: addJestPlugin, project: options.name, setupFile: 'none', skipSerializers: true, diff --git a/packages/nx-forge/src/generators/application/test-utils/mock-plugin-inference.spec-helper.ts b/packages/nx-forge/src/generators/application/test-utils/mock-plugin-inference.spec-helper.ts new file mode 100644 index 0000000..3f2a09d --- /dev/null +++ b/packages/nx-forge/src/generators/application/test-utils/mock-plugin-inference.spec-helper.ts @@ -0,0 +1,21 @@ +import * as devkit from '@nx/devkit'; +import type { ProjectGraph } from '@nx/devkit'; + +const emptyProjectGraph: ProjectGraph = { + dependencies: {}, + nodes: {}, +}; + +// Nx's inference generators consult the on-disk workspace graph even when the +// generator under test uses an in-memory Tree rooted at /virtual. Keep these +// unit tests at the Tree boundary; the e2e suite exercises real plugin discovery. +jest + .spyOn(devkit, 'createProjectGraphAsync') + .mockResolvedValue(emptyProjectGraph); + +jest.mock('nx/src/devkit-internals', () => ({ + ...jest.requireActual('nx/src/devkit-internals'), + retrieveProjectConfigurations: jest.fn(async () => ({ projects: {} })), +})); + +export {}; diff --git a/packages/nx-forge/src/migrations/update-2-3-0/remove-implicit-custom-ui-dependencies.spec.ts b/packages/nx-forge/src/migrations/update-2-3-0/remove-implicit-custom-ui-dependencies.spec.ts index d1ddb8a..c05a41d 100644 --- a/packages/nx-forge/src/migrations/update-2-3-0/remove-implicit-custom-ui-dependencies.spec.ts +++ b/packages/nx-forge/src/migrations/update-2-3-0/remove-implicit-custom-ui-dependencies.spec.ts @@ -17,7 +17,13 @@ describe('update 2.2.0 migration: remove-implicit-custom-ui-dependencies.spec', }); it('should remove Custom UI dependencies if they exist', async () => { - await generator(tree, { name: 'my-app', directory: 'myApp' }); + await generator(tree, { + name: 'my-app', + directory: 'myApp', + bundler: 'esbuild', + linter: 'none', + unitTestRunner: 'none', + }); const project = readProjectConfiguration(tree, 'my-app'); const customUIProjectName = 'custom-ui-project-1'; diff --git a/packages/nx-forge/tsconfig.lib.json b/packages/nx-forge/tsconfig.lib.json index f6be4d3..b372332 100644 --- a/packages/nx-forge/tsconfig.lib.json +++ b/packages/nx-forge/tsconfig.lib.json @@ -7,5 +7,10 @@ "strict": true }, "include": ["**/*.ts"], - "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"] + "exclude": [ + "**/*.spec.ts", + "**/*.spec-helper.ts", + "**/*.test.ts", + "jest.config.ts" + ] } diff --git a/tools/docs/project.json b/tools/docs/project.json index f0ec1fd..06b1093 100644 --- a/tools/docs/project.json +++ b/tools/docs/project.json @@ -37,9 +37,6 @@ ] } }, - "lint": { - "executor": "@nx/eslint:lint" - }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],