chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import {
|
||||
joinPathFragments,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
} from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
import pluginGenerator from '../plugin/plugin';
|
||||
import { createPackageGenerator } from './create-package';
|
||||
import { CreatePackageSchema } from './schema';
|
||||
import { setCwd } from '@nx/devkit/internal-testing-utils';
|
||||
import { tsLibVersion } from '@nx/js/internal';
|
||||
import { nxVersion } from 'nx/src/utils/versions';
|
||||
|
||||
const getSchema: (
|
||||
overrides?: Partial<CreatePackageSchema>
|
||||
) => CreatePackageSchema = (overrides = {}) => ({
|
||||
name: 'create-a-workspace',
|
||||
directory: 'packages/create-a-workspace',
|
||||
project: 'my-plugin',
|
||||
compiler: 'tsc',
|
||||
skipTsConfig: false,
|
||||
skipFormat: false,
|
||||
skipLintChecks: false,
|
||||
linter: 'eslint',
|
||||
unitTestRunner: 'jest',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('NxPlugin Create Package Generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(async () => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
setCwd('');
|
||||
await pluginGenerator(tree, {
|
||||
name: 'my-plugin',
|
||||
directory: 'packages/my-plugin',
|
||||
compiler: 'tsc',
|
||||
skipTsConfig: false,
|
||||
skipFormat: false,
|
||||
skipLintChecks: false,
|
||||
linter: 'eslint',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the project.json file', async () => {
|
||||
await createPackageGenerator(tree, getSchema());
|
||||
const project = readProjectConfiguration(tree, 'create-a-workspace');
|
||||
expect(project.root).toEqual('packages/create-a-workspace');
|
||||
expect(project.sourceRoot).toEqual('packages/create-a-workspace/bin');
|
||||
expect(project.targets.build).toEqual({
|
||||
executor: '@nx/js:tsc',
|
||||
outputs: ['{options.outputPath}'],
|
||||
options: {
|
||||
outputPath: 'dist/packages/create-a-workspace',
|
||||
tsConfig: 'packages/create-a-workspace/tsconfig.lib.json',
|
||||
main: 'packages/create-a-workspace/bin/index.ts',
|
||||
assets: ['packages/create-a-workspace/*.md'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should place the create-package plugin in a directory', async () => {
|
||||
await createPackageGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
directory: 'clis/create-a-workspace',
|
||||
} as Partial<CreatePackageSchema>)
|
||||
);
|
||||
const project = readProjectConfiguration(tree, 'create-a-workspace');
|
||||
expect(project.root).toEqual('clis/create-a-workspace');
|
||||
});
|
||||
|
||||
it('should create a preset generator in the plugin', async () => {
|
||||
await createPackageGenerator(tree, getSchema());
|
||||
|
||||
expect(
|
||||
tree.exists('packages/my-plugin/src/generators/preset/generator.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should specify tsc as compiler', async () => {
|
||||
await createPackageGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
compiler: 'tsc',
|
||||
})
|
||||
);
|
||||
|
||||
const { build } = readProjectConfiguration(
|
||||
tree,
|
||||
'create-a-workspace'
|
||||
).targets;
|
||||
|
||||
expect(build.executor).toEqual('@nx/js:tsc');
|
||||
});
|
||||
|
||||
it('should specify swc as compiler', async () => {
|
||||
await createPackageGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
compiler: 'swc',
|
||||
})
|
||||
);
|
||||
|
||||
const { build } = readProjectConfiguration(
|
||||
tree,
|
||||
'create-a-workspace'
|
||||
).targets;
|
||||
|
||||
expect(build.executor).toEqual('@nx/js:swc');
|
||||
});
|
||||
|
||||
it("should use name as default for the package.json's name", async () => {
|
||||
await createPackageGenerator(tree, getSchema());
|
||||
|
||||
const { root } = readProjectConfiguration(tree, 'create-a-workspace');
|
||||
const { name } = readJson<PackageJson>(
|
||||
tree,
|
||||
joinPathFragments(root, 'package.json')
|
||||
);
|
||||
|
||||
expect(name).toEqual('create-a-workspace');
|
||||
});
|
||||
|
||||
it("should have valid default package.json's dependencies", async () => {
|
||||
await createPackageGenerator(tree, getSchema());
|
||||
|
||||
const { root } = readProjectConfiguration(tree, 'create-a-workspace');
|
||||
const { dependencies } = readJson<PackageJson>(
|
||||
tree,
|
||||
joinPathFragments(root, 'package.json')
|
||||
);
|
||||
|
||||
expect(dependencies).toEqual(
|
||||
expect.objectContaining({
|
||||
'create-nx-workspace': nxVersion,
|
||||
tslib: tsLibVersion,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
addDependenciesToPackageJson,
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
GeneratorCallback,
|
||||
getPackageManagerCommand,
|
||||
joinPathFragments,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
runTasksInSerial,
|
||||
Tree,
|
||||
updateJson,
|
||||
updateProjectConfiguration,
|
||||
} from '@nx/devkit';
|
||||
import {
|
||||
libraryGenerator as jsLibraryGenerator,
|
||||
addTsLibDependencies,
|
||||
} from '@nx/js';
|
||||
import {
|
||||
getProjectSourceRoot,
|
||||
isUsingTsSolutionSetup,
|
||||
tsLibVersion,
|
||||
} from '@nx/js/internal';
|
||||
import type { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { nxVersion } from 'nx/src/utils/versions';
|
||||
import { join } from 'path';
|
||||
import { hasGenerator } from '../../utils/has-generator';
|
||||
import { generatorGenerator } from '../generator/generator';
|
||||
import { CreatePackageSchema } from './schema';
|
||||
import { NormalizedSchema, normalizeSchema } from './utils/normalize-schema';
|
||||
|
||||
export async function createPackageGenerator(
|
||||
host: Tree,
|
||||
schema: CreatePackageSchema
|
||||
) {
|
||||
return await createPackageGeneratorInternal(host, {
|
||||
useProjectJson: true,
|
||||
addPlugin: false,
|
||||
...schema,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPackageGeneratorInternal(
|
||||
host: Tree,
|
||||
schema: CreatePackageSchema
|
||||
) {
|
||||
const tasks: GeneratorCallback[] = [];
|
||||
|
||||
const options = await normalizeSchema(host, schema);
|
||||
const pluginPackageName = await addPresetGenerator(host, options);
|
||||
|
||||
if (options.bundler === 'tsc') {
|
||||
tasks.push(addTsLibDependencies(host));
|
||||
}
|
||||
|
||||
const installTask = addDependenciesToPackageJson(
|
||||
host,
|
||||
{
|
||||
'create-nx-workspace': nxVersion,
|
||||
},
|
||||
{}
|
||||
);
|
||||
tasks.push(installTask);
|
||||
|
||||
const cliPackageTask = await createCliPackage(
|
||||
host,
|
||||
options,
|
||||
pluginPackageName
|
||||
);
|
||||
tasks.push(cliPackageTask);
|
||||
|
||||
if (options.e2eProject) {
|
||||
addE2eProject(host, options);
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(host);
|
||||
}
|
||||
|
||||
return runTasksInSerial(...tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a preset generator to the plugin if it doesn't exist
|
||||
* @param host
|
||||
* @param schema
|
||||
* @returns package name of the plugin
|
||||
*/
|
||||
async function addPresetGenerator(
|
||||
host: Tree,
|
||||
schema: NormalizedSchema
|
||||
): Promise<string> {
|
||||
const { root: projectRoot } = readProjectConfiguration(host, schema.project);
|
||||
if (!hasGenerator(host, schema.project, 'preset')) {
|
||||
await generatorGenerator(host, {
|
||||
name: 'preset',
|
||||
path: join(projectRoot, 'src/generators/preset/generator'),
|
||||
unitTestRunner: schema.unitTestRunner,
|
||||
skipFormat: true,
|
||||
skipLintChecks: schema.linter === 'none',
|
||||
});
|
||||
}
|
||||
|
||||
return readJson(host, joinPathFragments(projectRoot, 'package.json'))?.name;
|
||||
}
|
||||
|
||||
async function createCliPackage(
|
||||
host: Tree,
|
||||
options: NormalizedSchema,
|
||||
pluginPackageName: string
|
||||
) {
|
||||
const jsLibraryTask = await jsLibraryGenerator(host, {
|
||||
...options,
|
||||
directory: options.directory,
|
||||
rootProject: false,
|
||||
config: 'project',
|
||||
publishable: true,
|
||||
bundler: options.bundler,
|
||||
importPath: options.name,
|
||||
skipFormat: true,
|
||||
skipTsConfig: true,
|
||||
useTscExecutor: true,
|
||||
});
|
||||
|
||||
host.delete(joinPathFragments(options.projectRoot, 'src'));
|
||||
|
||||
const isTsSolutionSetup = isUsingTsSolutionSetup(host);
|
||||
|
||||
// Add the bin entry to the package.json
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json'),
|
||||
(packageJson) => {
|
||||
packageJson.bin = {
|
||||
[options.name]: './bin/index.js',
|
||||
};
|
||||
if (isTsSolutionSetup) {
|
||||
packageJson.bin[options.name] = './dist/bin/index.js';
|
||||
// this package only exposes a binary entry point and no JS programmatic API
|
||||
delete packageJson.main;
|
||||
delete packageJson.types;
|
||||
delete packageJson.typings;
|
||||
delete packageJson.exports;
|
||||
}
|
||||
packageJson.dependencies = {
|
||||
'create-nx-workspace': nxVersion,
|
||||
...(options.bundler === 'tsc' && { tslib: tsLibVersion }),
|
||||
};
|
||||
return packageJson;
|
||||
}
|
||||
);
|
||||
|
||||
// update project build target to use the bin entry
|
||||
const projectConfiguration = readProjectConfiguration(
|
||||
host,
|
||||
options.projectName
|
||||
);
|
||||
projectConfiguration.sourceRoot = joinPathFragments(
|
||||
options.projectRoot,
|
||||
'bin'
|
||||
);
|
||||
projectConfiguration.targets.build.options.main = joinPathFragments(
|
||||
options.projectRoot,
|
||||
'bin/index.ts'
|
||||
);
|
||||
projectConfiguration.implicitDependencies = [options.project];
|
||||
if (options.isTsSolutionSetup) {
|
||||
if (options.bundler === 'tsc') {
|
||||
projectConfiguration.targets.build.options.generatePackageJson = false;
|
||||
} else if (options.bundler === 'swc') {
|
||||
delete projectConfiguration.targets.build.options.stripLeadingPaths;
|
||||
}
|
||||
}
|
||||
updateProjectConfiguration(host, options.projectName, projectConfiguration);
|
||||
|
||||
// Add bin files and update rootDir in tsconfg.lib.json
|
||||
updateJson(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'tsconfig.lib.json'),
|
||||
(tsConfig) => {
|
||||
tsConfig.include.push('bin/**/*.ts');
|
||||
tsConfig.compilerOptions ??= {};
|
||||
tsConfig.compilerOptions.rootDir = '.';
|
||||
return tsConfig;
|
||||
}
|
||||
);
|
||||
|
||||
generateFiles(
|
||||
host,
|
||||
joinPathFragments(__dirname, './files/create-framework-package'),
|
||||
options.projectRoot,
|
||||
{
|
||||
...options,
|
||||
preset: pluginPackageName,
|
||||
tmpl: '',
|
||||
}
|
||||
);
|
||||
|
||||
return jsLibraryTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a test file to plugin e2e project
|
||||
* @param host
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
function addE2eProject(host: Tree, options: NormalizedSchema) {
|
||||
const e2eProjectConfiguration = readProjectConfiguration(
|
||||
host,
|
||||
options.e2eProject
|
||||
);
|
||||
const projectConfiguration = readProjectConfiguration(host, options.project);
|
||||
const { name: pluginPackageName } = readJson(
|
||||
host,
|
||||
join(projectConfiguration.root, 'package.json')
|
||||
);
|
||||
|
||||
generateFiles(
|
||||
host,
|
||||
joinPathFragments(__dirname, './files/e2e'),
|
||||
getProjectSourceRoot(e2eProjectConfiguration, host),
|
||||
{
|
||||
pluginName: options.project,
|
||||
cliName: options.name,
|
||||
packageManagerCommands: getPackageManagerCommand(),
|
||||
pluginPackageName,
|
||||
tmpl: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createPackageGenerator;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createWorkspace } from 'create-nx-workspace';
|
||||
|
||||
async function main() {
|
||||
const name = process.argv[2]; // TODO: use libraries like yargs or enquirer to set your workspace name
|
||||
if (!name) {
|
||||
throw new Error('Please provide a name for the workspace');
|
||||
}
|
||||
|
||||
console.log(`Creating the workspace: ${name}`);
|
||||
|
||||
// This assumes "<%= preset %>" and "<%= projectName %>" are at the same version
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires<% if (isTsSolutionSetup) { %>
|
||||
const presetVersion = require('../../package.json').version;<% } else { %>
|
||||
const presetVersion = require('../package.json').version;<% } %>
|
||||
|
||||
// TODO: update below to customize the workspace
|
||||
const { directory } = await createWorkspace(
|
||||
`<%= preset %>@${presetVersion}`,
|
||||
{
|
||||
name,
|
||||
nxCloud: 'skip',
|
||||
packageManager: 'npm',
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Successfully created the workspace: ${directory}.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,58 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { mkdirSync, rmSync } from 'fs';
|
||||
|
||||
describe('<%= cliName %>', () => {
|
||||
let projectDirectory: string;
|
||||
|
||||
afterAll(() => {
|
||||
if (projectDirectory) {
|
||||
// Cleanup the test project
|
||||
rmSync(projectDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('should be installed', () => {
|
||||
projectDirectory = createTestProject();
|
||||
|
||||
// npm ls will fail if the package is not installed properly
|
||||
execSync('<%= packageManagerCommands.list %> <%= pluginPackageName %>', {
|
||||
cwd: projectDirectory,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a test project with create-nx-workspace and installs the plugin
|
||||
* @returns The directory where the test project was created
|
||||
*/
|
||||
function createTestProject(extraArgs = '') {
|
||||
const projectName = 'test-project';
|
||||
const projectDirectory = join(process.cwd(), 'tmp', projectName);
|
||||
|
||||
// Ensure projectDirectory is empty
|
||||
rmSync(projectDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
mkdirSync(dirname(projectDirectory), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
execSync(
|
||||
`<%= packageManagerCommands.dlx %> <%= cliName %>@e2e ${projectName} ${extraArgs}`,
|
||||
{
|
||||
cwd: dirname(projectDirectory),
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
console.log(`Created test project in "${projectDirectory}"`);
|
||||
|
||||
return projectDirectory;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Linter, LinterType } from '@nx/eslint';
|
||||
|
||||
export interface CreatePackageSchema {
|
||||
name: string;
|
||||
project: string;
|
||||
directory: string;
|
||||
|
||||
// options to create cli package, passed to js library generator
|
||||
skipFormat?: boolean;
|
||||
tags?: string;
|
||||
unitTestRunner?: 'jest' | 'vitest' | 'none';
|
||||
linter?: Linter | LinterType;
|
||||
compiler?: 'swc' | 'tsc';
|
||||
|
||||
// options to create e2e project, passed to e2e project generator
|
||||
e2eProject?: string;
|
||||
|
||||
useProjectJson?: boolean;
|
||||
addPlugin?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "NxPluginCreatePackage",
|
||||
"title": "Create a framework package",
|
||||
"description": "Create a framework package that uses Nx CLI.",
|
||||
"examplesFile": "../../../docs/generators/create-package-examples.md",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": "A directory where the app is placed.",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The package name of cli, e.g. `create-framework-package`. Note this must be a valid NPM name to be published.",
|
||||
"pattern": "create-.+|^@.+/create(?:-.+)?",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "The name of the generator project.",
|
||||
"alias": "p",
|
||||
"$default": {
|
||||
"$source": "projectName"
|
||||
},
|
||||
"x-prompt": "What is the name of the project for the generator?",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"unitTestRunner": {
|
||||
"type": "string",
|
||||
"enum": ["none", "jest", "vitest"],
|
||||
"description": "Test runner to use for unit tests."
|
||||
},
|
||||
"linter": {
|
||||
"description": "The tool to use for running lint checks.",
|
||||
"type": "string",
|
||||
"enum": ["none", "eslint"]
|
||||
},
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"description": "Add tags to the library (used for linting).",
|
||||
"alias": "t"
|
||||
},
|
||||
"skipFormat": {
|
||||
"description": "Skip formatting files.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"compiler": {
|
||||
"type": "string",
|
||||
"enum": ["tsc", "swc"],
|
||||
"default": "tsc",
|
||||
"description": "The compiler used by the build and test targets."
|
||||
},
|
||||
"e2eProject": {
|
||||
"type": "string",
|
||||
"description": "The name of the e2e project.",
|
||||
"x-prompt": "What is the name of the e2e project? Leave blank to skip e2e tests"
|
||||
},
|
||||
"useProjectJson": {
|
||||
"type": "boolean",
|
||||
"description": "Use a `project.json` configuration file instead of inlining the Nx configuration in the `package.json` file."
|
||||
}
|
||||
},
|
||||
"required": ["directory", "name", "project"]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readNxJson, Tree } from '@nx/devkit';
|
||||
import { determineProjectNameAndRootOptions } from '@nx/devkit/internal';
|
||||
import type { LinterType } from '@nx/eslint';
|
||||
import {
|
||||
normalizeLinterOption,
|
||||
normalizeUnitTestRunnerOption,
|
||||
isUsingTsSolutionSetup,
|
||||
shouldConfigureTsSolutionSetup,
|
||||
} from '@nx/js/internal';
|
||||
import { CreatePackageSchema } from '../schema';
|
||||
|
||||
export interface NormalizedSchema extends CreatePackageSchema {
|
||||
bundler: 'swc' | 'tsc';
|
||||
projectName: string;
|
||||
projectRoot: string;
|
||||
unitTestRunner: 'jest' | 'vitest' | 'none';
|
||||
linter: LinterType;
|
||||
useProjectJson: boolean;
|
||||
addPlugin: boolean;
|
||||
isTsSolutionSetup: boolean;
|
||||
}
|
||||
|
||||
export async function normalizeSchema(
|
||||
host: Tree,
|
||||
schema: CreatePackageSchema
|
||||
): Promise<NormalizedSchema> {
|
||||
const linter = await normalizeLinterOption(host, schema.linter);
|
||||
const unitTestRunner = await normalizeUnitTestRunnerOption(
|
||||
host,
|
||||
schema.unitTestRunner,
|
||||
['jest']
|
||||
);
|
||||
|
||||
if (!schema.directory) {
|
||||
throw new Error(
|
||||
`Please provide the --directory option. It should be the directory containing the project '${schema.project}'.`
|
||||
);
|
||||
}
|
||||
const {
|
||||
projectName,
|
||||
names: projectNames,
|
||||
projectRoot,
|
||||
} = await determineProjectNameAndRootOptions(host, {
|
||||
name: schema.name,
|
||||
projectType: 'library',
|
||||
directory: schema.directory,
|
||||
});
|
||||
|
||||
// this helper is called before the other generators that end up calling the
|
||||
// jsLibraryGenerator, so, if the TS solution setup is not configured, we
|
||||
// additionally check if the TS solution setup will be configured by the
|
||||
// jsLibraryGenerator
|
||||
const isTsSolutionSetup =
|
||||
isUsingTsSolutionSetup(host) ||
|
||||
shouldConfigureTsSolutionSetup(host, schema.addPlugin);
|
||||
const nxJson = readNxJson(host);
|
||||
const addPlugin =
|
||||
schema.addPlugin ??
|
||||
(isTsSolutionSetup &&
|
||||
process.env.NX_ADD_PLUGINS !== 'false' &&
|
||||
nxJson.useInferencePlugins !== false);
|
||||
|
||||
return {
|
||||
...schema,
|
||||
bundler: schema.compiler ?? 'tsc',
|
||||
projectName,
|
||||
projectRoot,
|
||||
name: projectNames.projectSimpleName,
|
||||
linter,
|
||||
unitTestRunner,
|
||||
// We default to generate a project.json file if the new setup is not being used
|
||||
useProjectJson: schema.useProjectJson ?? !isTsSolutionSetup,
|
||||
addPlugin,
|
||||
isTsSolutionSetup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import {
|
||||
Tree,
|
||||
addProjectConfiguration,
|
||||
readProjectConfiguration,
|
||||
readJson,
|
||||
getProjects,
|
||||
writeJson,
|
||||
updateJson,
|
||||
} from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { e2eProjectGenerator } from './e2e';
|
||||
|
||||
describe('NxPlugin e2e-project Generator', () => {
|
||||
let tree: Tree;
|
||||
let envBackup: string | undefined;
|
||||
beforeEach(() => {
|
||||
envBackup = process.env.ESLINT_USE_FLAT_CONFIG;
|
||||
delete process.env.ESLINT_USE_FLAT_CONFIG;
|
||||
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
|
||||
|
||||
// add a plugin project to the workspace for validations
|
||||
addProjectConfiguration(tree, 'my-plugin', {
|
||||
root: 'libs/my-plugin',
|
||||
targets: {},
|
||||
});
|
||||
writeJson(tree, 'libs/my-plugin/package.json', {
|
||||
name: 'my-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (envBackup === undefined) delete process.env.ESLINT_USE_FLAT_CONFIG;
|
||||
else process.env.ESLINT_USE_FLAT_CONFIG = envBackup;
|
||||
});
|
||||
|
||||
it('should validate the plugin name', async () => {
|
||||
await expect(
|
||||
e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
|
||||
await expect(
|
||||
e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-nonexistentplugin',
|
||||
pluginOutputPath: `dist/libs/my-nonexistentplugin`,
|
||||
npmPackageName: '@proj/my-nonexistentplugin',
|
||||
addPlugin: true,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should add files related to e2e', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
expect(tree.exists('my-plugin-e2e/tsconfig.json')).toBeTruthy();
|
||||
expect(tree.exists('my-plugin-e2e/src/my-plugin.spec.ts')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should extend from root tsconfig.base.json', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
const tsConfig = readJson(tree, 'my-plugin-e2e/tsconfig.json');
|
||||
expect(tsConfig.extends).toEqual('../tsconfig.base.json');
|
||||
});
|
||||
|
||||
it('should extend from root tsconfig.json when no tsconfig.base.json', async () => {
|
||||
tree.rename('tsconfig.base.json', 'tsconfig.json');
|
||||
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
const tsConfig = readJson(tree, 'my-plugin-e2e/tsconfig.json');
|
||||
expect(tsConfig.extends).toEqual('../tsconfig.json');
|
||||
});
|
||||
|
||||
it('should set project root with the directory option', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/namespace/my-plugin`,
|
||||
npmPackageName: '@proj/namespace-my-plugin',
|
||||
projectDirectory: 'namespace/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
const project = readProjectConfiguration(tree, 'my-plugin-e2e');
|
||||
expect(project.root).toBe('namespace/my-plugin-e2e');
|
||||
});
|
||||
|
||||
it('should update the implicit dependencies', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
const projects = Object.fromEntries(getProjects(tree));
|
||||
expect(projects).toMatchObject({
|
||||
'my-plugin-e2e': {
|
||||
implicitDependencies: ['my-plugin'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the workspace', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
const project = readProjectConfiguration(tree, 'my-plugin-e2e');
|
||||
|
||||
expect(project).toBeTruthy();
|
||||
expect(project.root).toEqual('my-plugin-e2e');
|
||||
expect(project.targets.e2e).toBeTruthy();
|
||||
expect(project.targets.e2e).toMatchInlineSnapshot(`
|
||||
{
|
||||
"dependsOn": [
|
||||
"^build",
|
||||
],
|
||||
"executor": "@nx/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "my-plugin-e2e/jest.config.cts",
|
||||
"runInBand": true,
|
||||
},
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage/{projectRoot}",
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not update create e2e target if target covered by existing plugin', async () => {
|
||||
updateJson(tree, 'nx.json', (json) => {
|
||||
return {
|
||||
...(json ?? {}),
|
||||
plugins: [
|
||||
...(json.plugins ?? []),
|
||||
{
|
||||
plugin: '@nx/jest/plugin',
|
||||
include: ['e2e/**/*'],
|
||||
options: {
|
||||
targetName: 'e2e',
|
||||
ciTargetName: 'e2e-ci',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
const project = readProjectConfiguration(tree, 'my-plugin-e2e');
|
||||
|
||||
expect(project).toBeTruthy();
|
||||
expect(project.root).toEqual('my-plugin-e2e');
|
||||
expect(project.targets.e2e).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should add jest support', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
const project = readProjectConfiguration(tree, 'my-plugin-e2e');
|
||||
|
||||
expect(project.targets.e2e).toMatchObject({
|
||||
options: expect.objectContaining({
|
||||
jestConfig: 'my-plugin-e2e/jest.config.cts',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(tree.exists('my-plugin-e2e/tsconfig.spec.json')).toBeTruthy();
|
||||
expect(tree.exists('my-plugin-e2e/jest.config.cts')).toBeTruthy();
|
||||
expect(tree.read('my-plugin-e2e/jest.config.cts', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"module.exports = {
|
||||
displayName: 'my-plugin-e2e',
|
||||
preset: '../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../coverage/my-plugin-e2e',
|
||||
globalSetup: '../tools/scripts/start-local-registry.ts',
|
||||
globalTeardown: '../tools/scripts/stop-local-registry.ts',
|
||||
};
|
||||
"
|
||||
`);
|
||||
expect(tree.exists('my-plugin-e2e/.spec.swcrc')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should setup the eslint builder', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
pluginOutputPath: `dist/libs/my-plugin`,
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
addPlugin: true,
|
||||
});
|
||||
|
||||
expect(tree.exists('my-plugin-e2e/eslint.config.mjs')).toBeTruthy();
|
||||
expect(tree.read('my-plugin-e2e/eslint.config.mjs', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"import baseConfig from '../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
describe('TS solution setup', () => {
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
updateJson(tree, 'package.json', (json) => {
|
||||
json.workspaces = ['packages/*'];
|
||||
return json;
|
||||
});
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
declaration: true,
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {
|
||||
extends: './tsconfig.base.json',
|
||||
files: [],
|
||||
references: [],
|
||||
});
|
||||
|
||||
// add a plugin project to the workspace for validations
|
||||
addProjectConfiguration(tree, 'my-plugin', {
|
||||
root: 'packages/my-plugin',
|
||||
});
|
||||
writeJson(tree, 'packages/my-plugin/package.json', {
|
||||
name: 'my-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add jest support', async () => {
|
||||
await e2eProjectGenerator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
npmPackageName: '@proj/my-plugin',
|
||||
projectDirectory: 'packages/my-plugin',
|
||||
pluginOutputPath: `dist/packages/my-plugin`,
|
||||
});
|
||||
|
||||
const project = readProjectConfiguration(tree, 'my-plugin-e2e');
|
||||
|
||||
expect(project.targets.e2e).toMatchObject({
|
||||
options: expect.objectContaining({
|
||||
jestConfig: 'packages/my-plugin-e2e/jest.config.cts',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('packages/my-plugin-e2e/tsconfig.spec.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('packages/my-plugin-e2e/jest.config.cts')
|
||||
).toBeTruthy();
|
||||
expect(tree.read('packages/my-plugin-e2e/jest.config.cts', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"/* eslint-disable */
|
||||
const { readFileSync } = require('fs');
|
||||
|
||||
// Reading the SWC compilation config for the spec files
|
||||
const swcJestConfig = JSON.parse(
|
||||
readFileSync(\`\${__dirname}/.spec.swcrc\`, 'utf-8'),
|
||||
);
|
||||
|
||||
// Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves
|
||||
swcJestConfig.swcrc = false;
|
||||
|
||||
module.exports = {
|
||||
displayName: 'my-plugin-e2e',
|
||||
preset: '../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\\\.[tj]s$': ['@swc/jest', swcJestConfig],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: 'test-output/jest/coverage',
|
||||
globalSetup: '../../tools/scripts/start-local-registry.ts',
|
||||
globalTeardown: '../../tools/scripts/stop-local-registry.ts',
|
||||
};
|
||||
"
|
||||
`);
|
||||
expect(tree.exists('packages/my-plugin-e2e/.spec.swcrc')).toBeTruthy();
|
||||
expect(tree.read('packages/my-plugin-e2e/.spec.swcrc', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"{
|
||||
"jsc": {
|
||||
"target": "es2017",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true,
|
||||
"dynamicImport": true
|
||||
},
|
||||
"transform": {
|
||||
"decoratorMetadata": true,
|
||||
"legacyDecorator": true
|
||||
},
|
||||
"keepClassNames": true,
|
||||
"externalHelpers": true,
|
||||
"loose": true
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
},
|
||||
"sourceMaps": true,
|
||||
"exclude": []
|
||||
}
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { determineProjectNameAndRootOptions } from '@nx/devkit/internal';
|
||||
import {
|
||||
addProjectConfiguration,
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
getPackageManagerCommand,
|
||||
joinPathFragments,
|
||||
names,
|
||||
offsetFromRoot,
|
||||
readJson,
|
||||
readNxJson,
|
||||
readProjectConfiguration,
|
||||
runTasksInSerial,
|
||||
updateJson,
|
||||
updateProjectConfiguration,
|
||||
writeJson,
|
||||
type GeneratorCallback,
|
||||
type ProjectConfiguration,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import { LinterType, lintProjectGenerator } from '@nx/eslint';
|
||||
import {
|
||||
addPropertyToJestConfig,
|
||||
configurationGenerator,
|
||||
findJestConfig,
|
||||
} from '@nx/jest';
|
||||
import { getRelativePathToRootTsConfig, setupVerdaccio } from '@nx/js';
|
||||
import {
|
||||
addLocalRegistryScripts,
|
||||
normalizeLinterOption,
|
||||
addProjectToTsSolutionWorkspace,
|
||||
isUsingTsSolutionSetup,
|
||||
} from '@nx/js/internal';
|
||||
import type { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { join } from 'path';
|
||||
import type { Schema } from './schema';
|
||||
|
||||
interface NormalizedSchema extends Schema {
|
||||
projectRoot: string;
|
||||
projectName: string;
|
||||
pluginPropertyName: string;
|
||||
linter: LinterType;
|
||||
useProjectJson: boolean;
|
||||
addPlugin: boolean;
|
||||
isTsSolutionSetup: boolean;
|
||||
}
|
||||
|
||||
async function normalizeOptions(
|
||||
host: Tree,
|
||||
options: Schema
|
||||
): Promise<NormalizedSchema> {
|
||||
const linter = await normalizeLinterOption(host, options.linter);
|
||||
|
||||
const projectName = options.rootProject ? 'e2e' : `${options.pluginName}-e2e`;
|
||||
|
||||
const nxJson = readNxJson(host);
|
||||
const addPlugin =
|
||||
options.addPlugin ??
|
||||
(process.env.NX_ADD_PLUGINS !== 'false' &&
|
||||
nxJson.useInferencePlugins !== false);
|
||||
|
||||
let projectRoot: string;
|
||||
const projectNameAndRootOptions = await determineProjectNameAndRootOptions(
|
||||
host,
|
||||
{
|
||||
name: projectName,
|
||||
projectType: 'application',
|
||||
directory:
|
||||
options.rootProject || !options.projectDirectory
|
||||
? projectName
|
||||
: `${options.projectDirectory}-e2e`,
|
||||
}
|
||||
);
|
||||
projectRoot = projectNameAndRootOptions.projectRoot;
|
||||
|
||||
const pluginPropertyName = names(options.pluginName).propertyName;
|
||||
const isTsSolutionSetup = isUsingTsSolutionSetup(host);
|
||||
|
||||
return {
|
||||
...options,
|
||||
projectName,
|
||||
linter,
|
||||
pluginPropertyName,
|
||||
projectRoot,
|
||||
addPlugin,
|
||||
useProjectJson: options.useProjectJson ?? !isTsSolutionSetup,
|
||||
isTsSolutionSetup,
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlugin(host: Tree, pluginName: string) {
|
||||
try {
|
||||
readProjectConfiguration(host, pluginName);
|
||||
} catch {
|
||||
throw new Error(`Project name "${pluginName}" doesn't not exist.`);
|
||||
}
|
||||
}
|
||||
|
||||
function addFiles(host: Tree, options: NormalizedSchema) {
|
||||
const projectConfiguration = readProjectConfiguration(
|
||||
host,
|
||||
options.pluginName
|
||||
);
|
||||
const { name: pluginPackageName } = readJson(
|
||||
host,
|
||||
join(projectConfiguration.root, 'package.json')
|
||||
);
|
||||
|
||||
const simplePluginName = options.pluginName.split('/').pop();
|
||||
generateFiles(host, join(__dirname, './files'), options.projectRoot, {
|
||||
...options,
|
||||
tmpl: '',
|
||||
rootTsConfigPath: getRelativePathToRootTsConfig(host, options.projectRoot),
|
||||
packageManagerCommands: getPackageManagerCommand(),
|
||||
pluginPackageName,
|
||||
simplePluginName,
|
||||
});
|
||||
}
|
||||
|
||||
async function addJest(host: Tree, options: NormalizedSchema) {
|
||||
const projectConfiguration: ProjectConfiguration = {
|
||||
name: options.projectName,
|
||||
root: options.projectRoot,
|
||||
projectType: 'application',
|
||||
sourceRoot: `${options.projectRoot}/src`,
|
||||
implicitDependencies: [options.pluginName],
|
||||
};
|
||||
|
||||
if (options.isTsSolutionSetup) {
|
||||
writeJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json'),
|
||||
{
|
||||
name: options.projectName,
|
||||
version: '0.0.1',
|
||||
private: true,
|
||||
}
|
||||
);
|
||||
updateProjectConfiguration(host, options.projectName, projectConfiguration);
|
||||
} else {
|
||||
projectConfiguration.targets = {};
|
||||
addProjectConfiguration(host, options.projectName, projectConfiguration);
|
||||
}
|
||||
|
||||
const jestTask = await configurationGenerator(host, {
|
||||
project: options.projectName,
|
||||
targetName: 'e2e',
|
||||
setupFile: 'none',
|
||||
supportTsx: false,
|
||||
skipSerializers: true,
|
||||
skipFormat: true,
|
||||
addPlugin: options.addPlugin,
|
||||
compiler: options.isTsSolutionSetup ? 'swc' : undefined,
|
||||
});
|
||||
|
||||
const { startLocalRegistryPath, stopLocalRegistryPath } =
|
||||
addLocalRegistryScripts(host);
|
||||
|
||||
const jestConfigPath = findJestConfig(host, options.projectRoot);
|
||||
if (!jestConfigPath) {
|
||||
throw new Error(
|
||||
`Could not find Jest config for project ${options.projectName} at ${options.projectRoot}`
|
||||
);
|
||||
}
|
||||
|
||||
addPropertyToJestConfig(
|
||||
host,
|
||||
jestConfigPath,
|
||||
'globalSetup',
|
||||
join(offsetFromRoot(options.projectRoot), startLocalRegistryPath)
|
||||
);
|
||||
addPropertyToJestConfig(
|
||||
host,
|
||||
jestConfigPath,
|
||||
'globalTeardown',
|
||||
join(offsetFromRoot(options.projectRoot), stopLocalRegistryPath)
|
||||
);
|
||||
|
||||
const project = readProjectConfiguration(host, options.projectName);
|
||||
project.targets ??= {};
|
||||
if (project.targets.e2e) {
|
||||
const e2eTarget = project.targets.e2e;
|
||||
|
||||
project.targets.e2e = {
|
||||
...e2eTarget,
|
||||
dependsOn: [`^build`],
|
||||
options: {
|
||||
...e2eTarget.options,
|
||||
runInBand: true,
|
||||
},
|
||||
};
|
||||
|
||||
updateProjectConfiguration(host, options.projectName, project);
|
||||
}
|
||||
|
||||
return jestTask;
|
||||
}
|
||||
|
||||
async function addLintingToApplication(
|
||||
tree: Tree,
|
||||
options: NormalizedSchema
|
||||
): Promise<GeneratorCallback> {
|
||||
const lintTask = await lintProjectGenerator(tree, {
|
||||
linter: options.linter,
|
||||
project: options.projectName,
|
||||
tsConfigPaths: [
|
||||
joinPathFragments(options.projectRoot, 'tsconfig.app.json'),
|
||||
],
|
||||
unitTestRunner: 'jest',
|
||||
skipFormat: true,
|
||||
setParserOptionsProject: false,
|
||||
addPlugin: options.addPlugin,
|
||||
});
|
||||
|
||||
return lintTask;
|
||||
}
|
||||
|
||||
function updatePluginPackageJson(tree: Tree, options: NormalizedSchema) {
|
||||
const { root } = readProjectConfiguration(tree, options.pluginName);
|
||||
updateJson(tree, joinPathFragments(root, 'package.json'), (json) => {
|
||||
// to publish the plugin, we need to remove the private flag
|
||||
delete json.private;
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
export async function e2eProjectGenerator(host: Tree, schema: Schema) {
|
||||
return await e2eProjectGeneratorInternal(host, {
|
||||
addPlugin: false,
|
||||
useProjectJson: true,
|
||||
...schema,
|
||||
});
|
||||
}
|
||||
|
||||
export async function e2eProjectGeneratorInternal(host: Tree, schema: Schema) {
|
||||
const tasks: GeneratorCallback[] = [];
|
||||
|
||||
validatePlugin(host, schema.pluginName);
|
||||
const options = await normalizeOptions(host, schema);
|
||||
addFiles(host, options);
|
||||
tasks.push(
|
||||
await setupVerdaccio(host, {
|
||||
skipFormat: true,
|
||||
})
|
||||
);
|
||||
tasks.push(await addJest(host, options));
|
||||
updatePluginPackageJson(host, options);
|
||||
|
||||
if (options.linter !== 'none') {
|
||||
tasks.push(
|
||||
await addLintingToApplication(host, {
|
||||
...options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (options.isTsSolutionSetup && !options.rootProject) {
|
||||
// update root tsconfig.json references with the new lib tsconfig
|
||||
updateJson(host, 'tsconfig.json', (json) => {
|
||||
json.references ??= [];
|
||||
json.references.push({
|
||||
path: options.projectRoot.startsWith('./')
|
||||
? options.projectRoot
|
||||
: './' + options.projectRoot,
|
||||
});
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
// If we are using the new TS solution
|
||||
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
|
||||
if (options.isTsSolutionSetup) {
|
||||
await addProjectToTsSolutionWorkspace(host, options.projectRoot);
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(host);
|
||||
}
|
||||
|
||||
return runTasksInSerial(...tasks);
|
||||
}
|
||||
|
||||
export default e2eProjectGenerator;
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { mkdirSync, rmSync } from 'fs';
|
||||
|
||||
describe('<%= pluginName %>', () => {
|
||||
let projectDirectory: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDirectory = createTestProject();
|
||||
|
||||
// The plugin has been built and published to a local registry in the jest globalSetup
|
||||
// Install the plugin built with the latest source code into the test repo
|
||||
execSync(`<%= packageManagerCommands.addDev %> <%= pluginPackageName %>@e2e`, {
|
||||
cwd: projectDirectory,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (projectDirectory) {
|
||||
// Cleanup the test project
|
||||
rmSync(projectDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('should be installed', () => {
|
||||
// npm ls will fail if the package is not installed properly
|
||||
execSync('<%= packageManagerCommands.list %> <%= pluginPackageName %>', {
|
||||
cwd: projectDirectory,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a test project with create-nx-workspace and installs the plugin
|
||||
* @returns The directory where the test project was created
|
||||
*/
|
||||
function createTestProject() {
|
||||
const projectName = 'test-project';
|
||||
const projectDirectory = join(process.cwd(), 'tmp', projectName);
|
||||
|
||||
// Ensure projectDirectory is empty
|
||||
rmSync(projectDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
mkdirSync(dirname(projectDirectory), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
execSync(
|
||||
`<%= packageManagerCommands.dlx %> create-nx-workspace@latest ${projectName} --preset apps --nxCloud=skip --no-interactive`,
|
||||
{
|
||||
cwd: dirname(projectDirectory),
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
console.log(`Created test project in "${projectDirectory}"`);
|
||||
|
||||
return projectDirectory;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "<%= rootTsConfigPath %>",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Linter, LinterType } from '@nx/eslint';
|
||||
|
||||
export interface Schema {
|
||||
pluginName: string;
|
||||
npmPackageName: string;
|
||||
projectDirectory?: string;
|
||||
pluginOutputPath?: string;
|
||||
jestConfig?: string;
|
||||
linter?: Linter | LinterType;
|
||||
skipFormat?: boolean;
|
||||
rootProject?: boolean;
|
||||
useProjectJson?: boolean;
|
||||
addPlugin?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "NxPluginE2E",
|
||||
"title": "Create an E2E app for a Nx Plugin",
|
||||
"description": "Create an E2E app for a Nx Plugin.",
|
||||
"examplesFile": "../../../docs/generators/e2e-project-examples.md",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pluginName": {
|
||||
"type": "string",
|
||||
"description": "the project name of the plugin to be tested.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"npmPackageName": {
|
||||
"type": "string",
|
||||
"description": "the package name of the plugin as it would be published to NPM.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"projectDirectory": {
|
||||
"type": "string",
|
||||
"description": "the directory where the plugin is placed."
|
||||
},
|
||||
"pluginOutputPath": {
|
||||
"type": "string",
|
||||
"description": "the output path of the plugin after it builds.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"jestConfig": {
|
||||
"type": "string",
|
||||
"description": "Jest config file."
|
||||
},
|
||||
"linter": {
|
||||
"description": "The tool to use for running lint checks.",
|
||||
"type": "string",
|
||||
"enum": ["none", "eslint"]
|
||||
},
|
||||
"minimal": {
|
||||
"type": "boolean",
|
||||
"description": "Generate the e2e project with a minimal setup. This would involve not generating tests for a default executor and generator.",
|
||||
"default": false
|
||||
},
|
||||
"skipFormat": {
|
||||
"description": "Skip formatting files.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"useProjectJson": {
|
||||
"type": "boolean",
|
||||
"description": "Use a `project.json` configuration file instead of inlining the Nx configuration in the `package.json` file."
|
||||
}
|
||||
},
|
||||
"required": ["pluginName", "npmPackageName"]
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import { Tree, readJson, readProjectConfiguration } from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { executorGenerator } from './executor';
|
||||
import { pluginGenerator } from '../plugin/plugin';
|
||||
import { libraryGenerator as jsLibraryGenerator } from '@nx/js';
|
||||
import { setCwd } from '@nx/devkit/internal-testing-utils';
|
||||
|
||||
describe('NxPlugin Executor Generator', () => {
|
||||
let tree: Tree;
|
||||
let projectName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectName = 'my-plugin';
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
setCwd('');
|
||||
|
||||
await pluginGenerator(tree, {
|
||||
directory: projectName,
|
||||
unitTestRunner: 'jest',
|
||||
linter: 'eslint',
|
||||
compiler: 'tsc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate files', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/executor.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/executor.spec.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle path with file extension', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/executor.ts',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/executor.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/executor.spec.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should generate files relative to the cwd', async () => {
|
||||
setCwd('my-plugin/src/executors/my-executor');
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
unitTestRunner: 'jest',
|
||||
path: 'my-plugin/src/executors/my-executor/executor',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/executor.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/executor.spec.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should update executors.json', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
const executorJson = readJson(tree, 'my-plugin/executors.json');
|
||||
|
||||
expect(executorJson.executors['my-executor'].implementation).toEqual(
|
||||
'./src/executors/my-executor/executor'
|
||||
);
|
||||
expect(executorJson.executors['my-executor'].schema).toEqual(
|
||||
'./src/executors/my-executor/schema.json'
|
||||
);
|
||||
expect(executorJson.executors['my-executor'].description).toEqual(
|
||||
'my-executor executor'
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate custom description', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/executor',
|
||||
description: 'my-executor custom description',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
const executorsJson = readJson(tree, 'my-plugin/executors.json');
|
||||
|
||||
expect(executorsJson.executors['my-executor'].description).toEqual(
|
||||
'my-executor custom description'
|
||||
);
|
||||
});
|
||||
|
||||
it('should create executors.json if it is not present', async () => {
|
||||
await jsLibraryGenerator(tree, {
|
||||
directory: 'test-js-lib',
|
||||
bundler: 'tsc',
|
||||
});
|
||||
const libConfig = readProjectConfiguration(tree, 'test-js-lib');
|
||||
|
||||
await executorGenerator(tree, {
|
||||
name: 'test-executor',
|
||||
path: 'test-js-lib/src/executors/my-executor/executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
expect(() => tree.exists(`${libConfig.root}/executors.json`)).not.toThrow();
|
||||
expect(readJson(tree, `${libConfig.root}/package.json`).executors).toBe(
|
||||
'./executors.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should support custom executor file name', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/my-custom-executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/my-custom-executor.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/executors/my-executor/my-custom-executor.spec.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/hasher.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/hasher.spec.ts')
|
||||
).toBeTruthy();
|
||||
expect(tree.read('my-plugin/executors.json', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"{
|
||||
"executors": {
|
||||
"my-executor": {
|
||||
"implementation": "./src/executors/my-executor/my-custom-executor",
|
||||
"schema": "./src/executors/my-executor/schema.json",
|
||||
"description": "my-executor executor",
|
||||
"hasher": "./src/executors/my-executor/hasher"
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
describe('--unitTestRunner', () => {
|
||||
describe('none', () => {
|
||||
it('should not generate unit test files', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/executor',
|
||||
unitTestRunner: 'none',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/executor.spec.ts')
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/hasher.spec.ts')
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('--includeHasher', () => {
|
||||
it('should generate hasher files', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: true,
|
||||
});
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/my-executor/hasher.spec.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.read('my-plugin/src/executors/my-executor/hasher.ts').toString()
|
||||
).toMatchInlineSnapshot(`
|
||||
"import type { CustomHasher } from '@nx/devkit';
|
||||
|
||||
/**
|
||||
* This is a boilerplate custom hasher that matches
|
||||
* the default Nx hasher. If you need to extend the behavior,
|
||||
* you can consume workspace details from the context.
|
||||
*/
|
||||
export const myExecutorHasher: CustomHasher = async (task, context) => {
|
||||
return context.hasher.hashTask(task, context.taskGraph);
|
||||
};
|
||||
|
||||
export default myExecutorHasher;
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should update executors.json', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'my-plugin/src/executors/my-executor/executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: true,
|
||||
});
|
||||
|
||||
const executorsJson = readJson(tree, 'my-plugin/executors.json');
|
||||
expect(executorsJson.executors['my-executor'].hasher).toEqual(
|
||||
'./src/executors/my-executor/hasher'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directory path handling', () => {
|
||||
it('should create subdirectory when path looks like a directory', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'artifact-upload',
|
||||
path: 'my-plugin/src/executors/artifact-upload',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
// Should create files in a subdirectory named after the artifact
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/executors/artifact-upload/artifact-upload.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/executors/artifact-upload/artifact-upload.spec.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/artifact-upload/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/artifact-upload/schema.json')
|
||||
).toBeTruthy();
|
||||
|
||||
const executorsJson = readJson(tree, 'my-plugin/executors.json');
|
||||
expect(executorsJson.executors['artifact-upload'].implementation).toEqual(
|
||||
'./src/executors/artifact-upload/artifact-upload'
|
||||
);
|
||||
expect(executorsJson.executors['artifact-upload'].schema).toEqual(
|
||||
'./src/executors/artifact-upload/schema.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle explicit file path correctly', async () => {
|
||||
await executorGenerator(tree, {
|
||||
name: 'artifact-upload',
|
||||
path: 'my-plugin/src/executors/artifact-upload/my-executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
});
|
||||
|
||||
// Should create files with the explicit filename
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/artifact-upload/my-executor.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/executors/artifact-upload/my-executor.spec.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/artifact-upload/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/executors/artifact-upload/schema.json')
|
||||
).toBeTruthy();
|
||||
|
||||
const executorsJson = readJson(tree, 'my-plugin/executors.json');
|
||||
expect(executorsJson.executors['artifact-upload'].implementation).toEqual(
|
||||
'./src/executors/artifact-upload/my-executor'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/internal';
|
||||
import {
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
joinPathFragments,
|
||||
names,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
updateJson,
|
||||
writeJson,
|
||||
type ExecutorsJson,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import { isUsingTsSolutionSetup } from '@nx/js/internal';
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { join } from 'path';
|
||||
import { getArtifactMetadataDirectory } from '../../utils/paths';
|
||||
import { nxVersion } from '../../utils/versions';
|
||||
import pluginLintCheckGenerator from '../lint-checks/generator';
|
||||
import type { Schema } from './schema';
|
||||
|
||||
interface NormalizedSchema extends Schema {
|
||||
className: string;
|
||||
propertyName: string;
|
||||
projectRoot: string;
|
||||
projectSourceRoot: string;
|
||||
fileName: string;
|
||||
directory: string;
|
||||
project: string;
|
||||
isTsSolutionSetup: boolean;
|
||||
}
|
||||
|
||||
function addFiles(host: Tree, options: NormalizedSchema) {
|
||||
generateFiles(host, join(__dirname, './files/executor'), options.directory, {
|
||||
...options,
|
||||
});
|
||||
|
||||
if (options.unitTestRunner === 'none') {
|
||||
host.delete(
|
||||
joinPathFragments(options.directory, `${options.fileName}.spec.ts`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addHasherFiles(host: Tree, options: NormalizedSchema) {
|
||||
generateFiles(host, join(__dirname, './files/hasher'), options.directory, {
|
||||
...options,
|
||||
});
|
||||
|
||||
if (options.unitTestRunner === 'none') {
|
||||
host.delete(joinPathFragments(options.directory, 'hasher.spec.ts'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExecutorsJson(
|
||||
host: Tree,
|
||||
projectRoot: string,
|
||||
projectName: string,
|
||||
skipLintChecks?: boolean
|
||||
) {
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
json.executors ??= './executors.json';
|
||||
return json;
|
||||
}
|
||||
);
|
||||
writeJson<ExecutorsJson>(
|
||||
host,
|
||||
joinPathFragments(projectRoot, 'executors.json'),
|
||||
{
|
||||
executors: {},
|
||||
}
|
||||
);
|
||||
if (!skipLintChecks) {
|
||||
await pluginLintCheckGenerator(host, {
|
||||
projectName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updateExecutorJson(host: Tree, options: NormalizedSchema) {
|
||||
const packageJson = readJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json')
|
||||
);
|
||||
|
||||
const packageJsonExecutors = packageJson.executors ?? packageJson.builders;
|
||||
let executorsPath = packageJsonExecutors
|
||||
? joinPathFragments(options.projectRoot, packageJsonExecutors)
|
||||
: null;
|
||||
|
||||
if (!executorsPath) {
|
||||
executorsPath = joinPathFragments(options.projectRoot, 'executors.json');
|
||||
}
|
||||
if (!host.exists(executorsPath)) {
|
||||
await createExecutorsJson(
|
||||
host,
|
||||
options.projectRoot,
|
||||
options.project,
|
||||
options.skipLintChecks
|
||||
);
|
||||
|
||||
if (options.isTsSolutionSetup) {
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
const filesSet = new Set(json.files ?? ['dist', '!**/*.tsbuildinfo']);
|
||||
filesSet.add('executors.json');
|
||||
json.files = [...filesSet];
|
||||
return json;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
// add dependencies
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
json.dependencies = {
|
||||
'@nx/devkit': nxVersion,
|
||||
...json.dependencies,
|
||||
};
|
||||
return json;
|
||||
}
|
||||
);
|
||||
|
||||
return updateJson(host, executorsPath, (json) => {
|
||||
let executors = json.executors ?? json.builders;
|
||||
executors ||= {};
|
||||
|
||||
const dir = getArtifactMetadataDirectory(
|
||||
host,
|
||||
options.project,
|
||||
options.directory,
|
||||
options.isTsSolutionSetup
|
||||
);
|
||||
executors[options.name] = {
|
||||
implementation: `${dir}/${options.fileName}`,
|
||||
schema: `${dir}/schema.json`,
|
||||
description: options.description,
|
||||
};
|
||||
if (options.includeHasher) {
|
||||
executors[options.name].hasher = `${dir}/hasher`;
|
||||
}
|
||||
json.executors = executors;
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeOptions(
|
||||
tree: Tree,
|
||||
options: Schema
|
||||
): Promise<NormalizedSchema> {
|
||||
const {
|
||||
artifactName: name,
|
||||
directory: initialDirectory,
|
||||
fileName,
|
||||
project,
|
||||
} = await determineArtifactNameAndDirectoryOptions(tree, {
|
||||
path: options.path,
|
||||
name: options.name,
|
||||
allowedFileExtensions: ['ts'],
|
||||
fileExtension: 'ts',
|
||||
});
|
||||
|
||||
// Check if the path looks like a directory path (doesn't end with a filename)
|
||||
// If so, include the artifact name as a subdirectory
|
||||
let directory = initialDirectory;
|
||||
const normalizedPath = options.path.replace(/^\.?\//, '').replace(/\/$/, '');
|
||||
const pathSegments = normalizedPath.split('/');
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
|
||||
// If the last segment doesn't end with a known file extension and matches the artifact name,
|
||||
// it's likely a directory path, so we should create a subdirectory
|
||||
const knownFileExtensions = ['.ts', '.js', '.jsx', '.tsx'];
|
||||
const hasKnownFileExtension = knownFileExtensions.some((ext) =>
|
||||
lastSegment.endsWith(ext)
|
||||
);
|
||||
if (!hasKnownFileExtension && lastSegment === name) {
|
||||
directory = joinPathFragments(initialDirectory, name);
|
||||
}
|
||||
|
||||
const { className, propertyName } = names(name);
|
||||
|
||||
const { root: projectRoot, sourceRoot: projectSourceRoot } =
|
||||
readProjectConfiguration(tree, project);
|
||||
|
||||
let description: string;
|
||||
if (options.description) {
|
||||
description = options.description;
|
||||
} else {
|
||||
description = `${name} executor`;
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
fileName,
|
||||
project,
|
||||
directory,
|
||||
name,
|
||||
className,
|
||||
propertyName,
|
||||
description,
|
||||
projectRoot,
|
||||
projectSourceRoot: projectSourceRoot ?? join(projectRoot, 'src'),
|
||||
isTsSolutionSetup: isUsingTsSolutionSetup(tree),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executorGenerator(host: Tree, schema: Schema) {
|
||||
const options = await normalizeOptions(host, schema);
|
||||
|
||||
addFiles(host, options);
|
||||
if (options.includeHasher) {
|
||||
addHasherFiles(host, options);
|
||||
}
|
||||
|
||||
await updateExecutorJson(host, options);
|
||||
|
||||
if (!schema.skipFormat) {
|
||||
await formatFiles(host);
|
||||
}
|
||||
}
|
||||
|
||||
export default executorGenerator;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ExecutorContext } from '@nx/devkit';
|
||||
|
||||
import { <%= className %>ExecutorSchema } from './schema';
|
||||
import executor from './<%= fileName %>';
|
||||
|
||||
const options: <%= className %>ExecutorSchema = {};
|
||||
const context: ExecutorContext = {
|
||||
root: '',
|
||||
cwd: process.cwd(),
|
||||
isVerbose: false,
|
||||
projectGraph: {
|
||||
nodes: {},
|
||||
dependencies: {},
|
||||
},
|
||||
projectsConfigurations: {
|
||||
projects: {},
|
||||
version: 2,
|
||||
},
|
||||
nxJsonConfiguration: {},
|
||||
};
|
||||
|
||||
describe('<%= className %> Executor', () => {
|
||||
it('can run', async () => {
|
||||
const output = await executor(options, context);
|
||||
expect(output.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PromiseExecutor } from "@nx/devkit";
|
||||
import type { <%= className %>ExecutorSchema } from './schema';
|
||||
|
||||
const runExecutor: PromiseExecutor<<%= className %>ExecutorSchema> = async (options) => {
|
||||
console.log('Executor ran for <%= className %>', options);
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
export default runExecutor;
|
||||
@@ -0,0 +1 @@
|
||||
export interface <%= className %>ExecutorSchema {} // eslint-disable-line
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"version": 2,
|
||||
"title": "<%= className %> executor",
|
||||
"description": "",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TaskHasher, HasherContext } from '@nx/devkit';
|
||||
|
||||
import { <%=propertyName%>Hasher } from './hasher';
|
||||
|
||||
describe('<%=propertyName%>Hasher', () => {
|
||||
it('should generate hash', async () => {
|
||||
const mockHasher: TaskHasher = {
|
||||
hashTask: jest.fn().mockReturnValue({value: 'hashed-task'})
|
||||
} as unknown as TaskHasher
|
||||
const hash = await <%=propertyName%>Hasher({
|
||||
id: 'my-task-id',
|
||||
target: {
|
||||
project: 'proj',
|
||||
target: 'target'
|
||||
},
|
||||
overrides: {},
|
||||
outputs: [],
|
||||
parallelism: true,
|
||||
cache: false
|
||||
}, {
|
||||
hasher: mockHasher
|
||||
} as unknown as HasherContext)
|
||||
expect(hash).toEqual({value: 'hashed-task'})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CustomHasher } from '@nx/devkit';
|
||||
|
||||
/**
|
||||
* This is a boilerplate custom hasher that matches
|
||||
* the default Nx hasher. If you need to extend the behavior,
|
||||
* you can consume workspace details from the context.
|
||||
*/
|
||||
export const <%=propertyName%>Hasher: CustomHasher = async (task, context) => {
|
||||
return context.hasher.hashTask(task, context.taskGraph);
|
||||
};
|
||||
|
||||
export default <%=propertyName%>Hasher;
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Schema {
|
||||
path: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
unitTestRunner: 'jest' | 'vitest' | 'none';
|
||||
includeHasher: boolean;
|
||||
skipLintChecks?: boolean;
|
||||
skipFormat?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "NxPluginExecutor",
|
||||
"title": "Create an Executor for an Nx Plugin",
|
||||
"description": "Create an Executor for an Nx Plugin.",
|
||||
"examplesFile": "../../../docs/generators/executor-examples.md",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The file path to the executor. Relative to the current working directory.",
|
||||
"x-prompt": "What is the executor file path?",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-priority": "important"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The executor name to export in the plugin executors collection."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Executor description.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"unitTestRunner": {
|
||||
"type": "string",
|
||||
"enum": ["jest", "vitest", "none"],
|
||||
"description": "Test runner to use for unit tests.",
|
||||
"default": "jest"
|
||||
},
|
||||
"includeHasher": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Should the boilerplate for a custom hasher be generated?"
|
||||
},
|
||||
"skipLintChecks": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Do not add an eslint configuration for plugin json files."
|
||||
},
|
||||
"skipFormat": {
|
||||
"type": "boolean",
|
||||
"description": "Skip formatting files.",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { Tree, readProjectConfiguration } from '@nx/devkit';
|
||||
|
||||
import { <%= generatorFnName %> } from './<%= fileName %>';
|
||||
import { <%= schemaInterfaceName %> } from './schema';
|
||||
|
||||
describe('<%= name %> generator', () => {
|
||||
let tree: Tree;
|
||||
const options: <%= schemaInterfaceName %> = { name: 'test' };
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
});
|
||||
|
||||
it('should run successfully', async () => {
|
||||
await <%= generatorFnName %>(tree, options);
|
||||
const config = readProjectConfiguration(tree, 'test');
|
||||
expect(config).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
addProjectConfiguration,
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import * as path from 'path';
|
||||
import type { <%= className %>GeneratorSchema } from './schema';
|
||||
|
||||
export async function <%= generatorFnName %> (tree: Tree, options: <%= schemaInterfaceName %>) {
|
||||
const projectRoot = `libs/${options.name}`;
|
||||
addProjectConfiguration(
|
||||
tree,
|
||||
options.name,
|
||||
{
|
||||
root: projectRoot,
|
||||
projectType: 'library',
|
||||
sourceRoot: `${projectRoot}/src`,
|
||||
targets: {}
|
||||
}
|
||||
);
|
||||
generateFiles(tree, path.join(__dirname, 'files'), projectRoot, options);
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
export default <%= generatorFnName %>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface <%= className %>GeneratorSchema {
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "<%= className %>",
|
||||
"title": "",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-prompt": "What name would you like to use?"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import {
|
||||
GeneratorsJson,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
} from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { libraryGenerator as jsLibraryGenerator } from '@nx/js';
|
||||
import { pluginGenerator } from '../plugin/plugin';
|
||||
import { generatorGenerator } from './generator';
|
||||
import { setCwd } from '@nx/devkit/internal-testing-utils';
|
||||
|
||||
describe('NxPlugin Generator Generator', () => {
|
||||
let tree: Tree;
|
||||
let projectName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectName = 'my-plugin';
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
setCwd('');
|
||||
await pluginGenerator(tree, {
|
||||
directory: projectName,
|
||||
unitTestRunner: 'jest',
|
||||
linter: 'eslint',
|
||||
compiler: 'tsc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate files', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.spec.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle path with file extension', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/generators/my-generator/generator.ts',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.spec.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should generate files relative to cwd', async () => {
|
||||
setCwd('my-plugin/src/nx-integrations/generators/my-generator');
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/nx-integrations/generators/my-generator/generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/nx-integrations/generators/my-generator/schema.d.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/nx-integrations/generators/my-generator/schema.json'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/nx-integrations/generators/my-generator/generator.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/nx-integrations/generators/my-generator/generator.spec.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should generate files for derived', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
path: `${projectName}/src/generators/my-generator/generator`,
|
||||
name: 'my-generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.spec.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should update generators.json', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
const generatorJson = readJson(tree, 'my-plugin/generators.json');
|
||||
|
||||
expect(generatorJson.generators['my-generator'].factory).toEqual(
|
||||
'./src/generators/my-generator/generator'
|
||||
);
|
||||
expect(generatorJson.generators['my-generator'].schema).toEqual(
|
||||
'./src/generators/my-generator/schema.json'
|
||||
);
|
||||
expect(generatorJson.generators['my-generator'].description).toEqual(
|
||||
'my-generator generator'
|
||||
);
|
||||
});
|
||||
|
||||
it('should update generators.json for derived', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
name: 'my-generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
const generatorJson = readJson(tree, 'my-plugin/generators.json');
|
||||
|
||||
expect(generatorJson.generators['my-generator'].factory).toEqual(
|
||||
'./src/generators/my-generator/generator'
|
||||
);
|
||||
expect(generatorJson.generators['my-generator'].schema).toEqual(
|
||||
'./src/generators/my-generator/schema.json'
|
||||
);
|
||||
expect(generatorJson.generators['my-generator'].description).toEqual(
|
||||
'my-generator generator'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if recreating an existing generator', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
expect(
|
||||
generatorGenerator(tree, {
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
name: 'my-generator',
|
||||
unitTestRunner: 'jest',
|
||||
})
|
||||
).rejects.toThrow('Generator my-generator already exists');
|
||||
});
|
||||
|
||||
it('should update generators.json with the same path as where the generator files folder is located', async () => {
|
||||
const generatorName = 'myGenerator';
|
||||
const generatorFileName = 'my-generator';
|
||||
|
||||
await generatorGenerator(tree, {
|
||||
name: generatorName,
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
unitTestRunner: 'jest',
|
||||
description: 'my-generator description',
|
||||
});
|
||||
|
||||
const generatorJson = readJson(tree, 'my-plugin/generators.json');
|
||||
|
||||
expect(
|
||||
tree.exists(`my-plugin/src/generators/${generatorFileName}/schema.d.ts`)
|
||||
).toBeTruthy();
|
||||
|
||||
expect(generatorJson.generators[generatorName].factory).toEqual(
|
||||
`./src/generators/${generatorFileName}/generator`
|
||||
);
|
||||
expect(generatorJson.generators[generatorName].schema).toEqual(
|
||||
`./src/generators/${generatorFileName}/schema.json`
|
||||
);
|
||||
expect(generatorJson.generators[generatorName].description).toEqual(
|
||||
`${generatorFileName} description`
|
||||
);
|
||||
});
|
||||
|
||||
it('should create generators.json if it is not present', async () => {
|
||||
await jsLibraryGenerator(tree, {
|
||||
directory: 'test-js-lib',
|
||||
bundler: 'tsc',
|
||||
});
|
||||
const libConfig = readProjectConfiguration(tree, 'test-js-lib');
|
||||
await generatorGenerator(tree, {
|
||||
name: 'test-generator',
|
||||
path: 'test-js-lib/src/generators/test-generator/generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
tree.exists(`${libConfig.root}/generators.json`)
|
||||
).not.toThrow();
|
||||
expect(readJson(tree, `${libConfig.root}/package.json`).generators).toBe(
|
||||
'./generators.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate custom description', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
description: 'my-generator custom description',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
const generatorJson = readJson(tree, 'my-plugin/generators.json');
|
||||
|
||||
expect(generatorJson.generators['my-generator'].description).toEqual(
|
||||
'my-generator custom description'
|
||||
);
|
||||
});
|
||||
|
||||
it('should support custom generator file name', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/generators/my-generator/my-custom-generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/schema.json')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/generators/my-generator/my-custom-generator.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/generators/my-generator/my-custom-generator.spec.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(tree.read('my-plugin/generators.json', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"{
|
||||
"generators": {
|
||||
"my-generator": {
|
||||
"factory": "./src/generators/my-generator/my-custom-generator",
|
||||
"schema": "./src/generators/my-generator/schema.json",
|
||||
"description": "my-generator generator"
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
describe('--unitTestRunner', () => {
|
||||
describe('none', () => {
|
||||
it('should not generate files', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'my-plugin/src/generators/my-generator/generator',
|
||||
unitTestRunner: 'none',
|
||||
});
|
||||
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/my-generator/generator.spec.ts')
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preset generator', () => {
|
||||
it('should default to standalone layout: true', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
path: 'my-plugin/src/generators/preset/generator',
|
||||
name: 'preset',
|
||||
unitTestRunner: 'none',
|
||||
});
|
||||
|
||||
const generatorJson = readJson<GeneratorsJson>(
|
||||
tree,
|
||||
'my-plugin/generators.json'
|
||||
);
|
||||
|
||||
expect(
|
||||
generatorJson.generators['preset']['x-use-standalone-layout']
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directory path handling', () => {
|
||||
it('should create subdirectory when path looks like a directory', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'component-generator',
|
||||
path: 'my-plugin/src/generators/component-generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
// Should create files in a subdirectory named after the artifact
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/generators/component-generator/component-generator.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/generators/component-generator/component-generator.spec.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/component-generator/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/component-generator/schema.json')
|
||||
).toBeTruthy();
|
||||
|
||||
const generatorsJson = readJson(tree, 'my-plugin/generators.json');
|
||||
expect(generatorsJson.generators['component-generator'].factory).toEqual(
|
||||
'./src/generators/component-generator/component-generator'
|
||||
);
|
||||
expect(generatorsJson.generators['component-generator'].schema).toEqual(
|
||||
'./src/generators/component-generator/schema.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle explicit file path correctly', async () => {
|
||||
await generatorGenerator(tree, {
|
||||
name: 'component-generator',
|
||||
path: 'my-plugin/src/generators/component-generator/my-generator',
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
|
||||
// Should create files with the explicit filename
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/generators/component-generator/my-generator.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'my-plugin/src/generators/component-generator/my-generator.spec.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/component-generator/schema.d.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('my-plugin/src/generators/component-generator/schema.json')
|
||||
).toBeTruthy();
|
||||
|
||||
const generatorsJson = readJson(tree, 'my-plugin/generators.json');
|
||||
expect(generatorsJson.generators['component-generator'].factory).toEqual(
|
||||
'./src/generators/component-generator/my-generator'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/internal';
|
||||
import {
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
GeneratorsJson,
|
||||
joinPathFragments,
|
||||
names,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
updateJson,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import { isUsingTsSolutionSetup } from '@nx/js/internal';
|
||||
import { join } from 'node:path';
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { hasGenerator } from '../../utils/has-generator';
|
||||
import { getArtifactMetadataDirectory } from '../../utils/paths';
|
||||
import { nxVersion } from '../../utils/versions';
|
||||
import pluginLintCheckGenerator from '../lint-checks/generator';
|
||||
import type { Schema } from './schema';
|
||||
|
||||
type NormalizedSchema = Schema & {
|
||||
directory: string;
|
||||
fileName: string;
|
||||
className: string;
|
||||
propertyName: string;
|
||||
projectRoot: string;
|
||||
projectSourceRoot: string;
|
||||
project: string;
|
||||
isTsSolutionSetup: boolean;
|
||||
};
|
||||
|
||||
async function normalizeOptions(
|
||||
tree: Tree,
|
||||
options: Schema
|
||||
): Promise<NormalizedSchema> {
|
||||
const {
|
||||
artifactName: name,
|
||||
directory: initialDirectory,
|
||||
fileName,
|
||||
project,
|
||||
} = await determineArtifactNameAndDirectoryOptions(tree, {
|
||||
path: options.path,
|
||||
name: options.name,
|
||||
allowedFileExtensions: ['ts'],
|
||||
fileExtension: 'ts',
|
||||
});
|
||||
|
||||
// Check if the path looks like a directory path (doesn't end with a filename)
|
||||
// If so, include the artifact name as a subdirectory
|
||||
let directory = initialDirectory;
|
||||
const normalizedPath = options.path.replace(/^\.?\//, '').replace(/\/$/, '');
|
||||
const pathSegments = normalizedPath.split('/');
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
|
||||
// If the last segment doesn't end with a known file extension and matches the artifact name,
|
||||
// it's likely a directory path, so we should create a subdirectory
|
||||
const knownFileExtensions = ['.ts', '.js', '.jsx', '.tsx'];
|
||||
const hasKnownFileExtension = knownFileExtensions.some((ext) =>
|
||||
lastSegment.endsWith(ext)
|
||||
);
|
||||
if (!hasKnownFileExtension && lastSegment === name) {
|
||||
directory = joinPathFragments(initialDirectory, name);
|
||||
}
|
||||
|
||||
const { className, propertyName } = names(name);
|
||||
|
||||
const { root: projectRoot, sourceRoot: projectSourceRoot } =
|
||||
readProjectConfiguration(tree, project);
|
||||
|
||||
let description: string;
|
||||
if (options.description) {
|
||||
description = options.description;
|
||||
} else {
|
||||
description = `${name} generator`;
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
directory,
|
||||
project,
|
||||
name,
|
||||
fileName,
|
||||
className,
|
||||
propertyName,
|
||||
description,
|
||||
projectRoot,
|
||||
projectSourceRoot: projectSourceRoot ?? join(projectRoot, 'src'),
|
||||
isTsSolutionSetup: isUsingTsSolutionSetup(tree),
|
||||
};
|
||||
}
|
||||
|
||||
function addFiles(host: Tree, options: NormalizedSchema) {
|
||||
const indexPath = join(options.directory, 'files/src/index.ts.template');
|
||||
|
||||
if (!host.exists(indexPath)) {
|
||||
host.write(indexPath, 'const variable = "<%= name %>";');
|
||||
}
|
||||
|
||||
generateFiles(host, join(__dirname, './files/generator'), options.directory, {
|
||||
...options,
|
||||
generatorFnName: `${options.propertyName}Generator`,
|
||||
schemaInterfaceName: `${options.className}GeneratorSchema`,
|
||||
});
|
||||
|
||||
if (options.unitTestRunner === 'none') {
|
||||
host.delete(join(options.directory, `${options.fileName}.spec.ts`));
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGeneratorsJson(
|
||||
host: Tree,
|
||||
projectRoot: string,
|
||||
projectName: string,
|
||||
skipLintChecks?: boolean,
|
||||
skipFormat?: boolean
|
||||
) {
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
json.generators ??= './generators.json';
|
||||
return json;
|
||||
}
|
||||
);
|
||||
writeJson<GeneratorsJson>(
|
||||
host,
|
||||
joinPathFragments(projectRoot, 'generators.json'),
|
||||
{
|
||||
generators: {},
|
||||
}
|
||||
);
|
||||
if (!skipLintChecks) {
|
||||
await pluginLintCheckGenerator(host, {
|
||||
projectName,
|
||||
skipFormat,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGeneratorJson(host: Tree, options: NormalizedSchema) {
|
||||
const packageJson = readJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json')
|
||||
);
|
||||
const packageJsonGenerators =
|
||||
packageJson.generators ?? packageJson.schematics;
|
||||
let generatorsPath = packageJsonGenerators
|
||||
? joinPathFragments(options.projectRoot, packageJsonGenerators)
|
||||
: null;
|
||||
|
||||
if (!generatorsPath) {
|
||||
generatorsPath = joinPathFragments(options.projectRoot, 'generators.json');
|
||||
}
|
||||
if (!host.exists(generatorsPath)) {
|
||||
await createGeneratorsJson(
|
||||
host,
|
||||
options.projectRoot,
|
||||
options.project,
|
||||
options.skipLintChecks,
|
||||
options.skipFormat
|
||||
);
|
||||
|
||||
if (options.isTsSolutionSetup) {
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
const filesSet = new Set(json.files ?? ['dist', '!**/*.tsbuildinfo']);
|
||||
filesSet.add('generators.json');
|
||||
json.files = [...filesSet];
|
||||
return json;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
// add dependencies
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
json.dependencies = {
|
||||
'@nx/devkit': nxVersion,
|
||||
...json.dependencies,
|
||||
};
|
||||
return json;
|
||||
}
|
||||
);
|
||||
|
||||
updateJson<GeneratorsJson>(host, generatorsPath, (json) => {
|
||||
let generators = json.generators ?? json.schematics;
|
||||
generators = generators || {};
|
||||
|
||||
const dir = getArtifactMetadataDirectory(
|
||||
host,
|
||||
options.project,
|
||||
options.directory,
|
||||
options.isTsSolutionSetup
|
||||
);
|
||||
generators[options.name] = {
|
||||
factory: `${dir}/${options.fileName}`,
|
||||
schema: `${dir}/schema.json`,
|
||||
description: options.description,
|
||||
};
|
||||
// @todo(v17): Remove this, prop is defunct.
|
||||
if (options.name === 'preset') {
|
||||
generators[options.name]['x-use-standalone-layout'] = true;
|
||||
}
|
||||
json.generators = generators;
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
export async function generatorGenerator(host: Tree, schema: Schema) {
|
||||
const options = await normalizeOptions(host, schema);
|
||||
if (hasGenerator(host, options.project, options.name)) {
|
||||
throw new Error(`Generator ${options.name} already exists.`);
|
||||
}
|
||||
|
||||
addFiles(host, options);
|
||||
|
||||
await updateGeneratorJson(host, options);
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(host);
|
||||
}
|
||||
}
|
||||
|
||||
export default generatorGenerator;
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Schema {
|
||||
path: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
unitTestRunner: 'jest' | 'vitest' | 'none';
|
||||
skipLintChecks?: boolean;
|
||||
skipFormat?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "NxPluginGenerator",
|
||||
"title": "Create a Generator for an Nx Plugin",
|
||||
"description": "Create a Generator for an Nx Plugin.",
|
||||
"type": "object",
|
||||
"examples": [
|
||||
{
|
||||
"description": "Generate a generator exported with the name matching the file name. It results in the generator `foo` at `mylib/src/generators/foo.ts`",
|
||||
"command": "nx g @nx/plugin:generator mylib/src/generators/foo.ts"
|
||||
},
|
||||
{
|
||||
"description": "Generate a generator without providing the file extension. It results in the generator `foo` at `mylib/src/generators/foo.ts`",
|
||||
"command": "nx g @nx/plugin:generator mylib/src/generators/foo"
|
||||
},
|
||||
{
|
||||
"description": "Generate a generator exported with a different name from the file name. It results in the generator `custom` at `mylib/src/generators/foo.ts`",
|
||||
"command": "nx g @nx/plugin:generator mylib/src/generators/foo --name=custom"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The file path to the generator. Relative to the current working directory.",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-prompt": "What is the generator file path?",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The generator name to export in the plugin generators collection."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Generator description.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"unitTestRunner": {
|
||||
"type": "string",
|
||||
"enum": ["jest", "vitest", "none"],
|
||||
"description": "Test runner to use for unit tests.",
|
||||
"default": "jest"
|
||||
},
|
||||
"skipLintChecks": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Do not add an eslint configuration for plugin json files."
|
||||
},
|
||||
"skipFormat": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Do not format files with prettier.",
|
||||
"x-priority": "internal"
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import {
|
||||
Tree,
|
||||
readProjectConfiguration,
|
||||
readJson,
|
||||
updateJson,
|
||||
joinPathFragments,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
|
||||
import type { Linter as ESLint } from 'eslint';
|
||||
|
||||
import generator from './generator';
|
||||
import pluginGenerator from '../plugin/plugin';
|
||||
import generatorGenerator from '../generator/generator';
|
||||
import executorGenerator from '../executor/executor';
|
||||
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
|
||||
describe('lint-checks generator', () => {
|
||||
let tree: Tree;
|
||||
let envBackup: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
envBackup = process.env.ESLINT_USE_FLAT_CONFIG;
|
||||
delete process.env.ESLINT_USE_FLAT_CONFIG;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (envBackup === undefined) delete process.env.ESLINT_USE_FLAT_CONFIG;
|
||||
else process.env.ESLINT_USE_FLAT_CONFIG = envBackup;
|
||||
});
|
||||
|
||||
async function setupTree(useFlat: boolean) {
|
||||
process.env.ESLINT_USE_FLAT_CONFIG = useFlat ? 'true' : 'false';
|
||||
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
|
||||
await pluginGenerator(tree, {
|
||||
directory: 'plugin',
|
||||
importPath: '@acme/plugin',
|
||||
compiler: 'tsc',
|
||||
linter: 'eslint',
|
||||
skipFormat: false,
|
||||
skipTsConfig: false,
|
||||
skipLintChecks: true, // manually call so we can update config files first
|
||||
unitTestRunner: 'jest',
|
||||
});
|
||||
await generatorGenerator(tree, {
|
||||
name: 'my-generator',
|
||||
path: 'plugin/src/generators/my-generator',
|
||||
unitTestRunner: 'jest',
|
||||
skipLintChecks: true,
|
||||
});
|
||||
await executorGenerator(tree, {
|
||||
name: 'my-executor',
|
||||
path: 'plugin/src/executors/my-executor',
|
||||
unitTestRunner: 'jest',
|
||||
includeHasher: false,
|
||||
skipLintChecks: true,
|
||||
});
|
||||
}
|
||||
|
||||
it('should update configuration files for default plugin (eslintrc)', async () => {
|
||||
await setupTree(false);
|
||||
await generator(tree, { projectName: 'plugin' });
|
||||
|
||||
const projectConfig = readProjectConfiguration(tree, 'plugin');
|
||||
const eslintConfig: ESLint.Config = readJson(
|
||||
tree,
|
||||
`${projectConfig.root}/.eslintrc.json`
|
||||
);
|
||||
|
||||
expect(eslintConfig.overrides).toContainEqual(
|
||||
expect.objectContaining({
|
||||
files: expect.arrayContaining([
|
||||
'./executors.json',
|
||||
'./package.json',
|
||||
'./generators.json',
|
||||
]),
|
||||
rules: {
|
||||
'@nx/nx-plugin-checks': 'error',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not duplicate configuration (eslintrc)', async () => {
|
||||
await setupTree(false);
|
||||
await generator(tree, { projectName: 'plugin' });
|
||||
await generator(tree, { projectName: 'plugin' });
|
||||
const projectConfig = readProjectConfiguration(tree, 'plugin');
|
||||
const eslintConfig: ESLint.Config = readJson(
|
||||
tree,
|
||||
`${projectConfig.root}/.eslintrc.json`
|
||||
);
|
||||
|
||||
expect(
|
||||
eslintConfig.overrides.find((x) => '@nx/nx-plugin-checks' in x.rules)
|
||||
).toMatchInlineSnapshot(`
|
||||
{
|
||||
"files": [
|
||||
"./package.json",
|
||||
"./generators.json",
|
||||
"./executors.json",
|
||||
],
|
||||
"parser": "jsonc-eslint-parser",
|
||||
"rules": {
|
||||
"@nx/nx-plugin-checks": "error",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should update configuration files for angular-style plugin (eslintrc)', async () => {
|
||||
await setupTree(false);
|
||||
const startingProjectConfig = readProjectConfiguration(tree, 'plugin');
|
||||
updateJson(
|
||||
tree,
|
||||
joinPathFragments(startingProjectConfig.root, 'package.json'),
|
||||
(json: PackageJson) => {
|
||||
json.schematics = './collection.json';
|
||||
delete json.generators;
|
||||
json.builders = './builders.json';
|
||||
delete json.executors;
|
||||
json['ng-update'] = './migrations.json';
|
||||
return json;
|
||||
}
|
||||
);
|
||||
writeJson(
|
||||
tree,
|
||||
joinPathFragments(startingProjectConfig.root, 'migrations.json'),
|
||||
{}
|
||||
);
|
||||
await generator(tree, { projectName: 'plugin' });
|
||||
const projectConfig = readProjectConfiguration(tree, 'plugin');
|
||||
const eslintConfig: ESLint.Config = readJson(
|
||||
tree,
|
||||
`${projectConfig.root}/.eslintrc.json`
|
||||
);
|
||||
|
||||
expect(eslintConfig.overrides).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"files": [
|
||||
"*.ts",
|
||||
"*.tsx",
|
||||
"*.js",
|
||||
"*.jsx",
|
||||
],
|
||||
"rules": {},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.ts",
|
||||
"*.tsx",
|
||||
],
|
||||
"rules": {},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.js",
|
||||
"*.jsx",
|
||||
],
|
||||
"rules": {},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.json",
|
||||
],
|
||||
"parser": "jsonc-eslint-parser",
|
||||
"rules": {
|
||||
"@nx/dependency-checks": [
|
||||
"error",
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"./package.json",
|
||||
"./collection.json",
|
||||
"./builders.json",
|
||||
"./migrations.json",
|
||||
],
|
||||
"parser": "jsonc-eslint-parser",
|
||||
"rules": {
|
||||
"@nx/nx-plugin-checks": "error",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should update configuration files for default plugin (flat config)', async () => {
|
||||
await setupTree(true);
|
||||
await generator(tree, { projectName: 'plugin' });
|
||||
|
||||
const projectConfig = readProjectConfiguration(tree, 'plugin');
|
||||
expect(tree.exists(`${projectConfig.root}/eslint.config.mjs`)).toBeTruthy();
|
||||
const eslintConfigContent = tree.read(
|
||||
`${projectConfig.root}/eslint.config.mjs`,
|
||||
'utf-8'
|
||||
);
|
||||
expect(eslintConfigContent).toContain('@nx/nx-plugin-checks');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
formatFiles,
|
||||
joinPathFragments,
|
||||
logger,
|
||||
output,
|
||||
ProjectConfiguration,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
TargetConfiguration,
|
||||
Tree,
|
||||
updateJson,
|
||||
updateProjectConfiguration,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
|
||||
import type { Schema as EsLintExecutorOptions } from '@nx/eslint/internal';
|
||||
|
||||
import { PluginLintChecksGeneratorSchema } from './schema';
|
||||
import { NX_PREFIX } from 'nx/src/utils/logger';
|
||||
import { PackageJson, readNxMigrateConfig } from 'nx/src/utils/package-json';
|
||||
import {
|
||||
addOverrideToLintConfig,
|
||||
findEslintFile,
|
||||
isEslintConfigSupported,
|
||||
lintConfigHasOverride,
|
||||
updateOverrideInLintConfig,
|
||||
useFlatConfig,
|
||||
} from '@nx/eslint/internal';
|
||||
|
||||
export default async function pluginLintCheckGenerator(
|
||||
host: Tree,
|
||||
options: PluginLintChecksGeneratorSchema
|
||||
) {
|
||||
const project = readProjectConfiguration(host, options.projectName);
|
||||
const packageJson = readJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(project.root, 'package.json')
|
||||
);
|
||||
|
||||
// This rule is eslint **only**
|
||||
if (projectIsEsLintEnabled(host, project)) {
|
||||
updateRootEslintConfig(host);
|
||||
updateProjectEslintConfig(host, project, packageJson);
|
||||
updateProjectTarget(host, options, packageJson);
|
||||
|
||||
// Project is setup for vscode
|
||||
if (host.exists('.vscode')) {
|
||||
setupVsCodeLintingForJsonFiles(host);
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`${NX_PREFIX} plugin lint checks can only be added to plugins which use eslint for linting`
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(host);
|
||||
}
|
||||
}
|
||||
|
||||
export function addMigrationJsonChecks(
|
||||
host: Tree,
|
||||
options: PluginLintChecksGeneratorSchema,
|
||||
packageJson: PackageJson
|
||||
) {
|
||||
const projectConfiguration = readProjectConfiguration(
|
||||
host,
|
||||
options.projectName
|
||||
);
|
||||
|
||||
if (!projectIsEsLintEnabled(host, projectConfiguration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [eslintTarget, eslintTargetConfiguration] =
|
||||
getEsLintOptions(projectConfiguration) ?? [];
|
||||
|
||||
const relativeMigrationsJsonPath =
|
||||
readNxMigrateConfig(packageJson).migrations;
|
||||
|
||||
if (!relativeMigrationsJsonPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationsJsonPath = joinPathFragments(
|
||||
projectConfiguration.root,
|
||||
relativeMigrationsJsonPath
|
||||
);
|
||||
|
||||
if (!eslintTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add path to lintFilePatterns if different than default "{projectRoot}"
|
||||
if (
|
||||
eslintTargetConfiguration.options?.lintFilePatterns &&
|
||||
!eslintTargetConfiguration.options.lintFilePatterns.includes(
|
||||
migrationsJsonPath
|
||||
)
|
||||
) {
|
||||
eslintTargetConfiguration.options.lintFilePatterns.push(migrationsJsonPath);
|
||||
updateProjectConfiguration(host, options.projectName, projectConfiguration);
|
||||
}
|
||||
|
||||
// Update project level eslintrc
|
||||
updateOverrideInLintConfig(
|
||||
host,
|
||||
projectConfiguration.root,
|
||||
(o) =>
|
||||
Object.keys(o.rules ?? {})?.includes('@nx/nx-plugin-checks') ||
|
||||
Object.keys(o.rules ?? {})?.includes('@nrwl/nx/nx-plugin-checks'),
|
||||
(o) => {
|
||||
const fileSet = new Set(Array.isArray(o.files) ? o.files : [o.files]);
|
||||
fileSet.add(relativeMigrationsJsonPath);
|
||||
return {
|
||||
...o,
|
||||
files: formatFilesEntries(host, Array.from(fileSet)),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function formatFilesEntries(tree: Tree, files: string[]): string[] {
|
||||
if (!useFlatConfig(tree)) {
|
||||
return files;
|
||||
}
|
||||
const filesAfter = files.map((f) => {
|
||||
const after = f.startsWith('./') ? f.replace('./', '**/') : f;
|
||||
return after;
|
||||
});
|
||||
return filesAfter;
|
||||
}
|
||||
|
||||
function updateProjectTarget(
|
||||
host: Tree,
|
||||
options: PluginLintChecksGeneratorSchema,
|
||||
packageJson: PackageJson
|
||||
) {
|
||||
const project = readProjectConfiguration(host, options.projectName);
|
||||
if (!project.targets) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [target, configuration] of Object.entries(project.targets)) {
|
||||
if (
|
||||
configuration.executor === '@nx/eslint:lint' &&
|
||||
// only add patterns if there are already hardcoded ones
|
||||
configuration.options?.lintFilePatterns
|
||||
) {
|
||||
const opts: EsLintExecutorOptions = configuration.options ?? {};
|
||||
opts.lintFilePatterns ??= [];
|
||||
|
||||
if (packageJson.generators) {
|
||||
opts.lintFilePatterns.push(
|
||||
joinPathFragments(project.root, packageJson.generators)
|
||||
);
|
||||
}
|
||||
if (
|
||||
packageJson.schematics &&
|
||||
packageJson.schematics !== packageJson.generators
|
||||
) {
|
||||
opts.lintFilePatterns.push(
|
||||
joinPathFragments(project.root, packageJson.schematics)
|
||||
);
|
||||
}
|
||||
if (packageJson.executors) {
|
||||
opts.lintFilePatterns.push(
|
||||
joinPathFragments(project.root, packageJson.executors)
|
||||
);
|
||||
}
|
||||
if (
|
||||
packageJson.builders &&
|
||||
packageJson.builders !== packageJson.executors
|
||||
) {
|
||||
opts.lintFilePatterns.push(
|
||||
joinPathFragments(project.root, packageJson.builders)
|
||||
);
|
||||
}
|
||||
opts.lintFilePatterns.push(`${project.root}/package.json`);
|
||||
opts.lintFilePatterns = [...new Set(opts.lintFilePatterns)];
|
||||
project.targets[target].options = opts;
|
||||
|
||||
// Plugin checks read schema/implementation files which may live in the
|
||||
// project's build outputs (and migration prompt files copied as assets).
|
||||
// Ensure lint runs after build and includes the relevant build outputs
|
||||
// as inputs so the sandbox allows the reads and the cache invalidates
|
||||
// when those outputs change.
|
||||
const targetConfig = project.targets[target];
|
||||
const dependsOn = new Set(targetConfig.dependsOn ?? []);
|
||||
dependsOn.add('build');
|
||||
targetConfig.dependsOn = Array.from(dependsOn);
|
||||
|
||||
const inputs = targetConfig.inputs ?? ['...'];
|
||||
const hasDependentOutputs = inputs.some(
|
||||
(input) =>
|
||||
typeof input === 'object' &&
|
||||
input !== null &&
|
||||
'dependentTasksOutputFiles' in input
|
||||
);
|
||||
if (!hasDependentOutputs) {
|
||||
inputs.push({
|
||||
dependentTasksOutputFiles: '**/*.{json,d.ts,js,md}',
|
||||
});
|
||||
}
|
||||
targetConfig.inputs = inputs;
|
||||
}
|
||||
}
|
||||
updateProjectConfiguration(host, options.projectName, project);
|
||||
}
|
||||
|
||||
function updateProjectEslintConfig(
|
||||
host: Tree,
|
||||
options: ProjectConfiguration,
|
||||
packageJson: PackageJson
|
||||
) {
|
||||
if (isEslintConfigSupported(host, options.root)) {
|
||||
const lookup = (o) =>
|
||||
Object.keys(o.rules ?? {}).includes('@nx/nx-plugin-checks') ||
|
||||
Object.keys(o.rules ?? {}).includes('@nrwl/nx/nx-plugin-checks');
|
||||
|
||||
const files = [
|
||||
'./package.json',
|
||||
packageJson.generators,
|
||||
packageJson.executors,
|
||||
packageJson.schematics,
|
||||
packageJson.builders,
|
||||
readNxMigrateConfig(packageJson).migrations,
|
||||
].filter((f) => !!f);
|
||||
|
||||
const parser = useFlatConfig(host)
|
||||
? { languageOptions: { parser: 'jsonc-eslint-parser' } }
|
||||
: { parser: 'jsonc-eslint-parser' };
|
||||
|
||||
if (lintConfigHasOverride(host, options.root, lookup)) {
|
||||
// update it
|
||||
updateOverrideInLintConfig(host, options.root, lookup, (o) => ({
|
||||
...o,
|
||||
files: formatFilesEntries(host, [
|
||||
...new Set([
|
||||
...(Array.isArray(o.files) ? o.files : [o.files]),
|
||||
...files,
|
||||
]),
|
||||
]),
|
||||
...parser,
|
||||
rules: {
|
||||
...o.rules,
|
||||
'@nx/nx-plugin-checks': 'error',
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// add it
|
||||
addOverrideToLintConfig(host, options.root, {
|
||||
files: formatFilesEntries(host, files),
|
||||
...parser,
|
||||
rules: {
|
||||
'@nx/nx-plugin-checks': 'error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the root eslint to specify a parser for json files
|
||||
// This is required, otherwise every json file that is not overriden
|
||||
// will display false errors in the IDE
|
||||
function updateRootEslintConfig(host: Tree) {
|
||||
if (isEslintConfigSupported(host)) {
|
||||
if (
|
||||
!lintConfigHasOverride(
|
||||
host,
|
||||
'',
|
||||
(o) =>
|
||||
Array.isArray(o.files)
|
||||
? o.files.some((f) => f.match(/\.json$/))
|
||||
: !!o.files?.match(/\.json$/),
|
||||
true
|
||||
)
|
||||
) {
|
||||
addOverrideToLintConfig(
|
||||
host,
|
||||
'',
|
||||
{
|
||||
files: '*.json',
|
||||
parser: 'jsonc-eslint-parser',
|
||||
rules: {},
|
||||
},
|
||||
{ checkBaseConfig: true }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
output.note({
|
||||
title: 'Unable to update root eslint config.',
|
||||
bodyLines: [
|
||||
'We only automatically update the root eslint config if it is json or flat config.',
|
||||
'If you are using a different format, you will need to update it manually.',
|
||||
'You need to set the parser to jsonc-eslint-parser for json files.',
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupVsCodeLintingForJsonFiles(host: Tree) {
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (host.exists('.vscode/settings.json')) {
|
||||
existing = readJson<Record<string, unknown>>(host, '.vscode/settings.json');
|
||||
} else {
|
||||
logger.info(
|
||||
`${NX_PREFIX} We've updated the vscode settings for this repository to ensure that plugin lint checks show up inside your IDE. This created .vscode/settings.json. To read more about this file, check vscode's documentation. It is frequently not committed, so other developers may need to add similar settings if they'd like to see the lint checks in the IDE rather than only during linting.`
|
||||
);
|
||||
}
|
||||
|
||||
// setup eslint validation for json files
|
||||
const eslintValidate = (existing['eslint.validate'] as string[]) ?? [];
|
||||
if (!eslintValidate.includes('json')) {
|
||||
existing['eslint.validate'] = [...eslintValidate, 'json'];
|
||||
}
|
||||
writeJson(host, '.vscode/settings.json', existing);
|
||||
}
|
||||
|
||||
function projectIsEsLintEnabled(tree: Tree, project: ProjectConfiguration) {
|
||||
return !!findEslintFile(tree, project.root);
|
||||
}
|
||||
|
||||
export function getEsLintOptions(
|
||||
project: ProjectConfiguration
|
||||
): [target: string, configuration: TargetConfiguration<EsLintExecutorOptions>] {
|
||||
return Object.entries(project.targets || {}).find(
|
||||
([, x]) =>
|
||||
x.executor === '@nx/eslint:lint' ||
|
||||
x.executor === '@nx/linter:eslint' ||
|
||||
x.executor === '@nrwl/linter:eslint'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface PluginLintChecksGeneratorSchema {
|
||||
projectName: string;
|
||||
skipFormat?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "PluginLint",
|
||||
"title": "",
|
||||
"type": "object",
|
||||
"description": "Adds linting configuration to validate common json files for nx plugins.",
|
||||
"examples": [
|
||||
{
|
||||
"command": "nx g @nx/plugin:lint-checks",
|
||||
"description": "Sets up linting configuration to validate common json files for an nx plugin project."
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"projectName": {
|
||||
"type": "string",
|
||||
"description": "Which project should be the configuration be added to?",
|
||||
"$default": {
|
||||
"$source": "projectName"
|
||||
},
|
||||
"x-priority": "important"
|
||||
},
|
||||
"skipFormat": {
|
||||
"type": "boolean",
|
||||
"x-priority": "internal",
|
||||
"description": "Skip formatting files with prettier.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["projectName"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { Tree } from '@nx/devkit';
|
||||
|
||||
import update from './<%= name %>';
|
||||
|
||||
describe('<%= name %> migration', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace({layout: 'apps-libs'});
|
||||
});
|
||||
|
||||
it('should run successfully', async () => {
|
||||
await update(tree);
|
||||
// ... expect changes made
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type { Tree } from '@nx/devkit';
|
||||
|
||||
export default function update(host: Tree) {
|
||||
// ...
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import {
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { migrationGenerator } from './migration';
|
||||
import { pluginGenerator } from '../plugin/plugin';
|
||||
import { setCwd } from '@nx/devkit/internal-testing-utils';
|
||||
|
||||
describe('NxPlugin migration generator', () => {
|
||||
let tree: Tree;
|
||||
let projectName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectName = 'my-plugin';
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
setCwd('');
|
||||
|
||||
await pluginGenerator(tree, {
|
||||
name: projectName,
|
||||
directory: 'packages/my-plugin',
|
||||
unitTestRunner: 'jest',
|
||||
linter: 'eslint',
|
||||
compiler: 'tsc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the workspace.json file', async () => {
|
||||
await migrationGenerator(tree, {
|
||||
path: `packages/my-plugin/${projectName}/update-1.0.0`,
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const project = readProjectConfiguration(tree, projectName);
|
||||
expect(project.root).toEqual('packages/my-plugin');
|
||||
expect(project.targets.build.options.assets).toContainEqual({
|
||||
input: './packages/my-plugin',
|
||||
glob: 'migrations.json',
|
||||
output: '.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate files', async () => {
|
||||
await migrationGenerator(tree, {
|
||||
name: 'my-migration',
|
||||
path: 'packages/my-plugin/migrations/1.0.0/my-migration',
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/my-plugin/migrations.json');
|
||||
const packageJson = readJson(tree, 'packages/my-plugin/package.json');
|
||||
|
||||
expect(
|
||||
tree.exists('packages/my-plugin/migrations/1.0.0/my-migration.ts')
|
||||
).toBeTruthy();
|
||||
|
||||
expect(migrationsJson.generators['my-migration'].version).toEqual('1.0.0');
|
||||
expect(migrationsJson.generators['my-migration'].description).toEqual(
|
||||
'Migration for v1.0.0'
|
||||
);
|
||||
expect(migrationsJson.generators['my-migration'].implementation).toEqual(
|
||||
'./migrations/1.0.0/my-migration'
|
||||
);
|
||||
expect(migrationsJson.packageJsonUpdates).toBeFalsy();
|
||||
|
||||
expect(packageJson['nx-migrations'].migrations).toEqual(
|
||||
'./migrations.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle path with file extension', async () => {
|
||||
await migrationGenerator(tree, {
|
||||
name: 'my-migration',
|
||||
path: 'packages/my-plugin/migrations/1.0.0/my-migration.ts',
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/my-plugin/migrations.json');
|
||||
const packageJson = readJson(tree, 'packages/my-plugin/package.json');
|
||||
|
||||
expect(
|
||||
tree.exists('packages/my-plugin/migrations/1.0.0/my-migration.ts')
|
||||
).toBeTruthy();
|
||||
|
||||
expect(migrationsJson.generators['my-migration'].version).toEqual('1.0.0');
|
||||
expect(migrationsJson.generators['my-migration'].description).toEqual(
|
||||
'Migration for v1.0.0'
|
||||
);
|
||||
expect(migrationsJson.generators['my-migration'].implementation).toEqual(
|
||||
'./migrations/1.0.0/my-migration'
|
||||
);
|
||||
expect(migrationsJson.packageJsonUpdates).toBeFalsy();
|
||||
|
||||
expect(packageJson['nx-migrations'].migrations).toEqual(
|
||||
'./migrations.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate files with default name', async () => {
|
||||
await migrationGenerator(tree, {
|
||||
description: 'my-migration description',
|
||||
path: 'packages/my-plugin/src/migrations/update-1.0.0/update-1.0.0',
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/my-plugin/migrations.json');
|
||||
|
||||
expect(
|
||||
tree.exists(
|
||||
'packages/my-plugin/src/migrations/update-1.0.0/update-1.0.0.ts'
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
expect(migrationsJson.generators['update-1.0.0'].implementation).toEqual(
|
||||
'./src/migrations/update-1.0.0/update-1.0.0'
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate files with default description', async () => {
|
||||
await migrationGenerator(tree, {
|
||||
name: 'my-migration',
|
||||
path: 'packages/my-plugin/src/migrations/update-1.0.0/update-1.0.0',
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/my-plugin/migrations.json');
|
||||
|
||||
expect(migrationsJson.generators['my-migration'].description).toEqual(
|
||||
'Migration for v1.0.0'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a $schema reference when creating migrations.json', async () => {
|
||||
await migrationGenerator(tree, {
|
||||
name: 'my-migration',
|
||||
path: 'packages/my-plugin/src/migrations/update-1.0.0/update-1.0.0',
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/my-plugin/migrations.json');
|
||||
|
||||
expect(migrationsJson.$schema).toEqual(
|
||||
'../../node_modules/nx/schemas/migrations-schema.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should compute the $schema offset from the project depth', async () => {
|
||||
await pluginGenerator(tree, {
|
||||
name: 'nested-plugin',
|
||||
directory: 'packages/group/nested-plugin',
|
||||
unitTestRunner: 'jest',
|
||||
linter: 'eslint',
|
||||
compiler: 'tsc',
|
||||
});
|
||||
|
||||
await migrationGenerator(tree, {
|
||||
name: 'my-migration',
|
||||
path: 'packages/group/nested-plugin/src/migrations/update-1.0.0/update-1.0.0',
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(
|
||||
tree,
|
||||
'packages/group/nested-plugin/migrations.json'
|
||||
);
|
||||
|
||||
// Depth 3 -> '../../../'; pins the offset to the project depth so a hardcoded
|
||||
// value (or dropping `offsetFromRoot`) would fail here.
|
||||
expect(migrationsJson.$schema).toEqual(
|
||||
'../../../node_modules/nx/schemas/migrations-schema.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add a $schema reference to an existing migrations.json', async () => {
|
||||
writeJson(tree, 'packages/my-plugin/migrations.json', { generators: {} });
|
||||
|
||||
await migrationGenerator(tree, {
|
||||
name: 'my-migration',
|
||||
path: 'packages/my-plugin/src/migrations/update-1.0.0/update-1.0.0',
|
||||
packageVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/my-plugin/migrations.json');
|
||||
|
||||
expect(migrationsJson.$schema).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate files with package.json updates', async () => {
|
||||
await migrationGenerator(tree, {
|
||||
name: 'my-migration',
|
||||
path: 'packages/my-plugin/src/migrations/update-1.0.0/update-1.0.0',
|
||||
packageVersion: '1.0.0',
|
||||
packageJsonUpdates: true,
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/my-plugin/migrations.json');
|
||||
|
||||
expect(migrationsJson.packageJsonUpdates).toEqual({
|
||||
['1.0.0']: {
|
||||
version: '1.0.0',
|
||||
packages: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/internal';
|
||||
import {
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
joinPathFragments,
|
||||
offsetFromRoot,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
updateJson,
|
||||
updateProjectConfiguration,
|
||||
writeJson,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import { isUsingTsSolutionSetup } from '@nx/js/internal';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { PackageJson, readNxMigrateConfig } from 'nx/src/utils/package-json';
|
||||
import { getArtifactMetadataDirectory } from '../../utils/paths';
|
||||
import { nxVersion } from '../../utils/versions';
|
||||
import { addMigrationJsonChecks } from '../lint-checks/generator';
|
||||
import type { Schema } from './schema';
|
||||
|
||||
interface NormalizedSchema extends Schema {
|
||||
directory: string;
|
||||
fileName: string;
|
||||
projectRoot: string;
|
||||
projectSourceRoot: string;
|
||||
project: string;
|
||||
isTsSolutionSetup: boolean;
|
||||
}
|
||||
|
||||
async function normalizeOptions(
|
||||
tree: Tree,
|
||||
options: Schema
|
||||
): Promise<NormalizedSchema> {
|
||||
const {
|
||||
artifactName: name,
|
||||
directory,
|
||||
fileName,
|
||||
project,
|
||||
} = await determineArtifactNameAndDirectoryOptions(tree, {
|
||||
path: options.path,
|
||||
name: options.name,
|
||||
allowedFileExtensions: ['ts'],
|
||||
fileExtension: 'ts',
|
||||
});
|
||||
|
||||
const { root: projectRoot, sourceRoot: projectSourceRoot } =
|
||||
readProjectConfiguration(tree, project);
|
||||
|
||||
const description: string =
|
||||
options.description ?? `Migration for v${options.packageVersion}`;
|
||||
|
||||
const normalized: NormalizedSchema = {
|
||||
...options,
|
||||
directory,
|
||||
fileName,
|
||||
project,
|
||||
name,
|
||||
description,
|
||||
projectRoot,
|
||||
projectSourceRoot: projectSourceRoot ?? join(projectRoot, 'src'),
|
||||
isTsSolutionSetup: isUsingTsSolutionSetup(tree),
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function addFiles(host: Tree, options: NormalizedSchema) {
|
||||
generateFiles(host, join(__dirname, 'files/migration'), options.directory, {
|
||||
...options,
|
||||
tmpl: '',
|
||||
});
|
||||
}
|
||||
|
||||
function updateMigrationsJson(host: Tree, options: NormalizedSchema) {
|
||||
const configuredMigrationPath = readNxMigrateConfig(
|
||||
readJson<PackageJson>(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json')
|
||||
)
|
||||
).migrations;
|
||||
const migrationsPath = joinPathFragments(
|
||||
options.projectRoot,
|
||||
configuredMigrationPath ?? 'migrations.json'
|
||||
);
|
||||
const migrations = host.exists(migrationsPath)
|
||||
? readJson(host, migrationsPath)
|
||||
: {
|
||||
$schema: `${offsetFromRoot(
|
||||
dirname(migrationsPath)
|
||||
)}node_modules/nx/schemas/migrations-schema.json`,
|
||||
};
|
||||
|
||||
const generators = migrations.generators ?? {};
|
||||
|
||||
const dir = getArtifactMetadataDirectory(
|
||||
host,
|
||||
options.project,
|
||||
options.directory,
|
||||
options.isTsSolutionSetup
|
||||
);
|
||||
generators[options.name] = {
|
||||
version: options.packageVersion,
|
||||
description: options.description,
|
||||
implementation: `${dir}/${options.fileName}`,
|
||||
};
|
||||
migrations.generators = generators;
|
||||
|
||||
if (options.packageJsonUpdates) {
|
||||
const packageJsonUpdatesObj = migrations.packageJsonUpdates ?? {};
|
||||
if (!packageJsonUpdatesObj[options.packageVersion]) {
|
||||
packageJsonUpdatesObj[options.packageVersion] = {
|
||||
version: options.packageVersion,
|
||||
packages: {},
|
||||
};
|
||||
}
|
||||
migrations.packageJsonUpdates = packageJsonUpdatesObj;
|
||||
}
|
||||
|
||||
writeJson(host, migrationsPath, migrations);
|
||||
}
|
||||
|
||||
function updatePackageJson(host: Tree, options: NormalizedSchema) {
|
||||
updateJson<PackageJson>(
|
||||
host,
|
||||
join(options.projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
const addFile = (file: string) => {
|
||||
if (options.isTsSolutionSetup) {
|
||||
const filesSet = new Set(json.files ?? ['dist', '!**/*.tsbuildinfo']);
|
||||
filesSet.add(file.replace(/^\.\//, ''));
|
||||
json.files = [...filesSet];
|
||||
}
|
||||
};
|
||||
|
||||
const migrationKey = json['ng-update'] ? 'ng-update' : 'nx-migrations';
|
||||
if (typeof json[migrationKey] === 'string') {
|
||||
addFile(json[migrationKey]);
|
||||
return json;
|
||||
} else if (!json[migrationKey]) {
|
||||
json[migrationKey] = {
|
||||
migrations: './migrations.json',
|
||||
};
|
||||
} else if (json[migrationKey].migrations) {
|
||||
json[migrationKey].migrations = './migrations.json';
|
||||
}
|
||||
|
||||
addFile(json[migrationKey].migrations);
|
||||
|
||||
// add dependencies
|
||||
json.dependencies = {
|
||||
'@nx/devkit': nxVersion,
|
||||
...json.dependencies,
|
||||
};
|
||||
|
||||
return json;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function updateProjectConfig(host: Tree, options: NormalizedSchema) {
|
||||
if (options.isTsSolutionSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const project = readProjectConfiguration(host, options.project);
|
||||
|
||||
const assets = project.targets.build?.options?.assets;
|
||||
if (
|
||||
assets &&
|
||||
(assets as any[]).filter((a) => a.glob === 'migrations.json').length === 0
|
||||
) {
|
||||
project.targets.build.options.assets = [
|
||||
...assets,
|
||||
{
|
||||
input: `./${options.projectRoot}`,
|
||||
glob: 'migrations.json',
|
||||
output: '.',
|
||||
},
|
||||
];
|
||||
updateProjectConfiguration(host, options.project, project);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrationGenerator(host: Tree, schema: Schema) {
|
||||
const options = await normalizeOptions(host, schema);
|
||||
|
||||
addFiles(host, options);
|
||||
updatePackageJson(host, options);
|
||||
updateMigrationsJson(host, options);
|
||||
updateProjectConfig(host, options);
|
||||
|
||||
if (!host.exists('migrations.json')) {
|
||||
const packageJsonPath = joinPathFragments(
|
||||
options.projectRoot,
|
||||
'package.json'
|
||||
);
|
||||
addMigrationJsonChecks(
|
||||
host,
|
||||
{ projectName: options.project },
|
||||
readJson<PackageJson>(host, packageJsonPath)
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(host);
|
||||
}
|
||||
}
|
||||
|
||||
export default migrationGenerator;
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Schema {
|
||||
path: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
packageVersion: string;
|
||||
packageJsonUpdates?: boolean;
|
||||
skipFormat?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "NxPluginMigration",
|
||||
"title": "Create a Migration for an Nx Plugin",
|
||||
"description": "Create a Migration for an Nx Plugin.",
|
||||
"type": "object",
|
||||
"examples": [
|
||||
{
|
||||
"description": "Generate a migration exported with the name matching the file name, which will be triggered when migrating to version 1.0.0 or above from a previous version. It results in the migration `foo` at `mylib/src/migrations/foo.ts`",
|
||||
"command": "nx g @nx/plugin:migration mylib/src/migrations/foo.ts -v=1.0.0"
|
||||
},
|
||||
{
|
||||
"description": "Generate a migration without providing the file extension, which will be triggered when migrating to version 1.0.0 or above from a previous version. It results in the migration `foo` at `mylib/src/migrations/foo.ts`",
|
||||
"command": "nx g @nx/plugin:migration mylib/src/migrations/foo -v=1.0.0"
|
||||
},
|
||||
{
|
||||
"description": "Generate a migration exported with a different name from the file name, which will be triggered when migrating to version 1.0.0 or above from a previous version. It results in the migration `custom` at `mylib/src/migrations/foo.ts`",
|
||||
"command": "nx g @nx/plugin:migration mylib/src/migrations/foo --name=custom -v=1.0.0"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The file path to the migration without the file extension. Relative to the current working directory.",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-prompt": "What is the migration file path?",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The migration name to export in the plugin migrations collection."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Migration description."
|
||||
},
|
||||
"packageVersion": {
|
||||
"type": "string",
|
||||
"description": "Version to use for the migration.",
|
||||
"alias": "v",
|
||||
"x-prompt": "What version would you like to use for the migration?",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"packageJsonUpdates": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not to include `package.json` updates.",
|
||||
"alias": "p",
|
||||
"default": false
|
||||
},
|
||||
"skipLintChecks": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Do not eslint configuration for plugin json files."
|
||||
}
|
||||
},
|
||||
"required": ["packageVersion", "path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import {
|
||||
getProjects,
|
||||
joinPathFragments,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
updateJson,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { pluginGenerator } from './plugin';
|
||||
import { Schema } from './schema';
|
||||
|
||||
const getSchema: (overrides?: Partial<Schema>) => Schema = (
|
||||
overrides = {}
|
||||
) => ({
|
||||
directory: 'my-plugin',
|
||||
compiler: 'tsc',
|
||||
skipTsConfig: false,
|
||||
skipFormat: false,
|
||||
skipLintChecks: false,
|
||||
linter: 'eslint',
|
||||
unitTestRunner: 'jest',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('NxPlugin Plugin Generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
|
||||
});
|
||||
|
||||
it('should update the project configuration', async () => {
|
||||
await pluginGenerator(tree, getSchema());
|
||||
const project = readProjectConfiguration(tree, 'my-plugin');
|
||||
expect(project.root).toEqual('my-plugin');
|
||||
expect(project.targets.build).toEqual({
|
||||
executor: '@nx/js:tsc',
|
||||
outputs: ['{options.outputPath}'],
|
||||
options: {
|
||||
outputPath: 'dist/my-plugin',
|
||||
tsConfig: 'my-plugin/tsconfig.lib.json',
|
||||
main: 'my-plugin/src/index.ts',
|
||||
assets: [
|
||||
'my-plugin/*.md',
|
||||
{
|
||||
input: './my-plugin/src',
|
||||
glob: '**/!(*.ts)',
|
||||
output: './src',
|
||||
},
|
||||
{
|
||||
input: './my-plugin/src',
|
||||
glob: '**/*.d.ts',
|
||||
output: './src',
|
||||
},
|
||||
{
|
||||
input: './my-plugin',
|
||||
glob: 'generators.json',
|
||||
output: '.',
|
||||
},
|
||||
{
|
||||
input: './my-plugin',
|
||||
glob: 'executors.json',
|
||||
output: '.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should place the plugin in a directory', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
name: 'my-plugin',
|
||||
directory: 'plugins/my-plugin',
|
||||
})
|
||||
);
|
||||
const project = readProjectConfiguration(tree, 'my-plugin');
|
||||
const projectE2e = readProjectConfiguration(tree, 'my-plugin-e2e');
|
||||
expect(project.root).toEqual('plugins/my-plugin');
|
||||
expect(projectE2e.root).toEqual('plugins/my-plugin-e2e');
|
||||
});
|
||||
|
||||
it('should place the e2e project in the specified directory', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
name: 'my-plugin',
|
||||
directory: 'packages/my-plugin',
|
||||
e2eTestRunner: 'jest',
|
||||
e2eProjectDirectory: 'my-plugin',
|
||||
})
|
||||
);
|
||||
const project = readProjectConfiguration(tree, 'my-plugin');
|
||||
const projectE2e = readProjectConfiguration(tree, 'my-plugin-e2e');
|
||||
expect(project.root).toEqual('packages/my-plugin');
|
||||
expect(projectE2e.root).toEqual('my-plugin-e2e');
|
||||
});
|
||||
|
||||
describe('asset paths', () => {
|
||||
it('should generate normalized asset paths for plugin in monorepo', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
directory: 'my-plugin',
|
||||
})
|
||||
);
|
||||
const project = readProjectConfiguration(tree, 'my-plugin');
|
||||
const assets = project.targets.build.options.assets;
|
||||
expect(assets).toEqual([
|
||||
'my-plugin/*.md',
|
||||
{
|
||||
input: './my-plugin/src',
|
||||
glob: '**/!(*.ts)',
|
||||
output: './src',
|
||||
},
|
||||
{
|
||||
input: './my-plugin/src',
|
||||
glob: '**/*.d.ts',
|
||||
output: './src',
|
||||
},
|
||||
{
|
||||
input: './my-plugin',
|
||||
glob: 'generators.json',
|
||||
output: '.',
|
||||
},
|
||||
{
|
||||
input: './my-plugin',
|
||||
glob: 'executors.json',
|
||||
output: '.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate normalized asset paths for plugin in standalone workspace', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
name: 'my-plugin',
|
||||
directory: '.',
|
||||
})
|
||||
);
|
||||
const project = readProjectConfiguration(tree, 'my-plugin');
|
||||
const assets = project.targets.build.options.assets;
|
||||
expect(assets).toEqual([
|
||||
'*.md',
|
||||
{
|
||||
input: './src',
|
||||
glob: '**/!(*.ts)',
|
||||
output: './src',
|
||||
},
|
||||
{
|
||||
input: './src',
|
||||
glob: '**/*.d.ts',
|
||||
output: './src',
|
||||
},
|
||||
{
|
||||
input: '.',
|
||||
glob: 'generators.json',
|
||||
output: '.',
|
||||
},
|
||||
{
|
||||
input: '.',
|
||||
glob: 'executors.json',
|
||||
output: '.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('--unitTestRunner', () => {
|
||||
describe('none', () => {
|
||||
it('should not generate test files', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
directory: 'my-plugin',
|
||||
unitTestRunner: 'none',
|
||||
})
|
||||
);
|
||||
|
||||
['my-plugin/jest.config.cts'].forEach((path) =>
|
||||
expect(tree.exists(path)).toBeFalsy()
|
||||
);
|
||||
|
||||
['my-plugin/vite.config.ts'].forEach((path) =>
|
||||
expect(tree.exists(path)).toBeFalsy()
|
||||
);
|
||||
|
||||
expect(
|
||||
readProjectConfiguration(tree, 'my-plugin').targets.test
|
||||
).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('jest', () => {
|
||||
it('should generate test files with jest.config.cts', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
directory: 'my-plugin',
|
||||
unitTestRunner: 'jest',
|
||||
})
|
||||
);
|
||||
|
||||
expect(tree.exists('my-plugin/jest.config.cts')).toBeTruthy();
|
||||
expect(tree.read('my-plugin/jest.config.cts', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"module.exports = {
|
||||
displayName: 'my-plugin',
|
||||
preset: '../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../coverage/my-plugin',
|
||||
};
|
||||
"
|
||||
`);
|
||||
expect(tree.exists('my-plugin/.spec.swcrc')).toBeFalsy();
|
||||
|
||||
const projectTargets = readProjectConfiguration(
|
||||
tree,
|
||||
'my-plugin'
|
||||
).targets;
|
||||
|
||||
expect(projectTargets.test).toBeDefined();
|
||||
expect(projectTargets.test?.executor).toEqual('@nx/jest:jest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vitest', () => {
|
||||
it('should generate test files with vitest.config.mts', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
directory: 'my-plugin',
|
||||
unitTestRunner: 'vitest',
|
||||
})
|
||||
);
|
||||
|
||||
expect(tree.exists('my-plugin/vite.config.ts')).toBeFalsy();
|
||||
['my-plugin/vitest.config.mts'].forEach((path) =>
|
||||
expect(tree.exists(path)).toBeTruthy()
|
||||
);
|
||||
|
||||
const projectTargets = readProjectConfiguration(
|
||||
tree,
|
||||
'my-plugin'
|
||||
).targets;
|
||||
|
||||
expect(projectTargets.test).toBeDefined();
|
||||
expect(projectTargets.test?.executor).toEqual('@nx/vitest:test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('--compiler', () => {
|
||||
it('should specify tsc as compiler', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
compiler: 'tsc',
|
||||
})
|
||||
);
|
||||
|
||||
const { build } = readProjectConfiguration(tree, 'my-plugin').targets;
|
||||
|
||||
expect(build.executor).toEqual('@nx/js:tsc');
|
||||
});
|
||||
|
||||
it('should specify swc as compiler', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
compiler: 'swc',
|
||||
})
|
||||
);
|
||||
|
||||
const { build } = readProjectConfiguration(tree, 'my-plugin').targets;
|
||||
|
||||
expect(build.executor).toEqual('@nx/js:swc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--importPath', () => {
|
||||
it('should use the workspace npmScope by default for the package.json', async () => {
|
||||
await pluginGenerator(tree, getSchema());
|
||||
|
||||
const { root } = readProjectConfiguration(tree, 'my-plugin');
|
||||
const { name } = readJson<PackageJson>(
|
||||
tree,
|
||||
joinPathFragments(root, 'package.json')
|
||||
);
|
||||
|
||||
expect(name).toEqual('@proj/my-plugin');
|
||||
});
|
||||
|
||||
it('should correctly setup npmScope less workspaces', async () => {
|
||||
updateJson(tree, 'package.json', (j) => {
|
||||
j.name = 'source';
|
||||
return j;
|
||||
});
|
||||
|
||||
await pluginGenerator(tree, getSchema());
|
||||
|
||||
const { root } = readProjectConfiguration(tree, 'my-plugin');
|
||||
const { name } = readJson<PackageJson>(
|
||||
tree,
|
||||
joinPathFragments(root, 'package.json')
|
||||
);
|
||||
|
||||
expect(name).toEqual('my-plugin');
|
||||
});
|
||||
|
||||
it('should use importPath as the package.json name', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({ importPath: '@my-company/my-plugin' })
|
||||
);
|
||||
|
||||
const { root } = readProjectConfiguration(tree, 'my-plugin');
|
||||
const { name } = readJson<PackageJson>(
|
||||
tree,
|
||||
joinPathFragments(root, 'package.json')
|
||||
);
|
||||
|
||||
expect(name).toEqual('@my-company/my-plugin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--e2eTestRunner', () => {
|
||||
it('should allow the e2e project to be skipped', async () => {
|
||||
await pluginGenerator(tree, getSchema({ e2eTestRunner: 'none' }));
|
||||
const projects = getProjects(tree);
|
||||
expect(projects.has('my-plugin-e2e')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TS solution setup', () => {
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
tree.write('.gitignore', '');
|
||||
updateJson(tree, 'package.json', (json) => {
|
||||
json.workspaces = ['packages/*'];
|
||||
return json;
|
||||
});
|
||||
writeJson(tree, 'tsconfig.base.json', {
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
declaration: true,
|
||||
customConditions: ['@proj/source'],
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'tsconfig.json', {
|
||||
extends: './tsconfig.base.json',
|
||||
files: [],
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate test files with jest.config.cts', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
directory: 'my-plugin',
|
||||
unitTestRunner: 'jest',
|
||||
useProjectJson: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(tree.exists('my-plugin/jest.config.cts')).toBeTruthy();
|
||||
expect(tree.read('my-plugin/jest.config.cts', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"/* eslint-disable */
|
||||
const { readFileSync } = require('fs');
|
||||
|
||||
// Reading the SWC compilation config for the spec files
|
||||
const swcJestConfig = JSON.parse(
|
||||
readFileSync(\`\${__dirname}/.spec.swcrc\`, 'utf-8'),
|
||||
);
|
||||
|
||||
// Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves
|
||||
swcJestConfig.swcrc = false;
|
||||
|
||||
module.exports = {
|
||||
displayName: '@proj/my-plugin',
|
||||
preset: '../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\\\.[tj]s$': ['@swc/jest', swcJestConfig],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: 'test-output/jest/coverage',
|
||||
};
|
||||
"
|
||||
`);
|
||||
expect(tree.exists('my-plugin/.spec.swcrc')).toBeTruthy();
|
||||
expect(tree.read('my-plugin/.spec.swcrc', 'utf-8'))
|
||||
.toMatchInlineSnapshot(`
|
||||
"{
|
||||
"jsc": {
|
||||
"target": "es2017",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true,
|
||||
"dynamicImport": true
|
||||
},
|
||||
"transform": {
|
||||
"decoratorMetadata": true,
|
||||
"legacyDecorator": true
|
||||
},
|
||||
"keepClassNames": true,
|
||||
"externalHelpers": true,
|
||||
"loose": true
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
},
|
||||
"sourceMaps": true,
|
||||
"exclude": []
|
||||
}
|
||||
"
|
||||
`);
|
||||
|
||||
const projectTargets = readProjectConfiguration(
|
||||
tree,
|
||||
'@proj/my-plugin'
|
||||
).targets;
|
||||
|
||||
expect(projectTargets.test).toBeDefined();
|
||||
expect(projectTargets.test?.executor).toEqual('@nx/jest:jest');
|
||||
});
|
||||
|
||||
it('should add project references when using TS solution', async () => {
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
e2eTestRunner: 'jest',
|
||||
})
|
||||
);
|
||||
|
||||
expect(readJson(tree, 'tsconfig.json').references).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"path": "./my-plugin",
|
||||
},
|
||||
{
|
||||
"path": "./my-plugin-e2e",
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(readJson(tree, 'my-plugin/package.json')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0",
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"@proj/source": "./src/index.ts",
|
||||
"default": "./dist/index.js",
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"name": "@proj/my-plugin",
|
||||
"type": "commonjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"version": "0.0.1",
|
||||
}
|
||||
`);
|
||||
expect(readJson(tree, 'my-plugin/tsconfig.json')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json",
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
expect(readJson(tree, 'my-plugin/tsconfig.lib.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"compilerOptions": {
|
||||
"emitDeclarationOnly": false,
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
|
||||
"types": [
|
||||
"node",
|
||||
],
|
||||
},
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
],
|
||||
"extends": "../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
],
|
||||
"references": [],
|
||||
}
|
||||
`);
|
||||
expect(readJson(tree, 'my-plugin/tsconfig.spec.json'))
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"outDir": "./out-tsc/jest",
|
||||
"types": [
|
||||
"jest",
|
||||
"node",
|
||||
],
|
||||
},
|
||||
"extends": "../tsconfig.base.json",
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts",
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not set the custom condition in exports when it does not exist in tsconfig.base.json', async () => {
|
||||
updateJson(tree, 'tsconfig.base.json', (json) => {
|
||||
delete json.compilerOptions.customConditions;
|
||||
return json;
|
||||
});
|
||||
|
||||
await pluginGenerator(
|
||||
tree,
|
||||
getSchema({
|
||||
e2eTestRunner: 'jest',
|
||||
skipFormat: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
readJson(tree, 'my-plugin/package.json').exports['.']
|
||||
).not.toHaveProperty('development');
|
||||
});
|
||||
|
||||
it('should set "nx.name" in package.json when the user provides a name that is different than the package name and "useProjectJson" is "false"', async () => {
|
||||
await pluginGenerator(tree, {
|
||||
directory: 'my-plugin',
|
||||
name: 'my-plugin', // import path contains the npm scope, so it would be different
|
||||
useProjectJson: false,
|
||||
skipFormat: true,
|
||||
});
|
||||
|
||||
expect(readJson(tree, 'my-plugin/package.json').nx.name).toBe(
|
||||
'my-plugin'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set "nx.name" in package.json when the provided name matches the package name', async () => {
|
||||
await pluginGenerator(tree, {
|
||||
directory: 'my-plugin',
|
||||
name: '@proj/my-plugin',
|
||||
useProjectJson: false,
|
||||
skipFormat: true,
|
||||
});
|
||||
|
||||
expect(readJson(tree, 'my-plugin/package.json').nx.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not set "nx" in package.json when "useProjectJson" is "true"', async () => {
|
||||
await pluginGenerator(tree, {
|
||||
directory: 'my-plugin',
|
||||
name: '@proj/my-plugin',
|
||||
useProjectJson: true,
|
||||
skipFormat: true,
|
||||
});
|
||||
|
||||
expect(readJson(tree, 'my-plugin/package.json').nx).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not set "nx.name" in package.json when the user does not provide a name', async () => {
|
||||
await pluginGenerator(tree, {
|
||||
directory: 'my-plugin',
|
||||
useProjectJson: false,
|
||||
skipFormat: true,
|
||||
});
|
||||
|
||||
expect(readJson(tree, 'my-plugin/package.json').nx.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
addDependenciesToPackageJson,
|
||||
formatFiles,
|
||||
generateFiles,
|
||||
GeneratorCallback,
|
||||
joinPathFragments,
|
||||
normalizePath,
|
||||
readProjectConfiguration,
|
||||
runTasksInSerial,
|
||||
Tree,
|
||||
updateJson,
|
||||
updateProjectConfiguration,
|
||||
} from '@nx/devkit';
|
||||
import {
|
||||
libraryGenerator as jsLibraryGenerator,
|
||||
addTsLibDependencies,
|
||||
} from '@nx/js';
|
||||
import {
|
||||
addSwcDependencies,
|
||||
addSwcRegisterDependencies,
|
||||
getProjectSourceRoot,
|
||||
} from '@nx/js/internal';
|
||||
import * as path from 'path';
|
||||
import { e2eProjectGenerator } from '../e2e-project/e2e';
|
||||
import pluginLintCheckGenerator from '../lint-checks/generator';
|
||||
import type { Schema } from './schema';
|
||||
import { NormalizedSchema, normalizeOptions } from './utils/normalize-schema';
|
||||
|
||||
const nxVersion = require(path.join('@nx/plugin', 'package.json')).version;
|
||||
|
||||
async function addFiles(host: Tree, options: NormalizedSchema) {
|
||||
host.delete(normalizePath(`${options.projectRoot}/src/lib`));
|
||||
|
||||
generateFiles(
|
||||
host,
|
||||
path.join(__dirname, './files/plugin'),
|
||||
options.projectRoot,
|
||||
{
|
||||
...options,
|
||||
tmpl: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function updatePluginConfig(host: Tree, options: NormalizedSchema) {
|
||||
const project = readProjectConfiguration(host, options.projectName);
|
||||
|
||||
if (project.targets.build) {
|
||||
if (options.isTsSolutionSetup && options.bundler === 'tsc') {
|
||||
project.targets.build.options.rootDir = getProjectSourceRoot(
|
||||
project,
|
||||
host
|
||||
);
|
||||
project.targets.build.options.generatePackageJson = false;
|
||||
}
|
||||
|
||||
project.targets.build.options.assets = [
|
||||
...(project.targets.build.options.assets ?? []),
|
||||
];
|
||||
|
||||
const root = options.projectRoot === '.' ? '.' : './' + options.projectRoot;
|
||||
|
||||
if (options.isTsSolutionSetup) {
|
||||
project.targets.build.options.assets.push(
|
||||
{ input: `${root}/src`, glob: '**/!(*.ts)', output: '.' },
|
||||
{ input: `${root}/src`, glob: '**/*.d.ts', output: '.' }
|
||||
);
|
||||
} else {
|
||||
project.targets.build.options.assets.push(
|
||||
{ input: `${root}/src`, glob: '**/!(*.ts)', output: './src' },
|
||||
{ input: `${root}/src`, glob: '**/*.d.ts', output: './src' },
|
||||
{ input: root, glob: 'generators.json', output: '.' },
|
||||
{ input: root, glob: 'executors.json', output: '.' }
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectConfiguration(host, options.projectName, project);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pluginGenerator(tree: Tree, schema: Schema) {
|
||||
return await pluginGeneratorInternal(tree, {
|
||||
useProjectJson: true,
|
||||
addPlugin: false,
|
||||
...schema,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pluginGeneratorInternal(host: Tree, schema: Schema) {
|
||||
const options = await normalizeOptions(host, schema);
|
||||
const tasks: GeneratorCallback[] = [];
|
||||
|
||||
tasks.push(
|
||||
await jsLibraryGenerator(host, {
|
||||
...schema,
|
||||
name: options.name,
|
||||
directory: options.projectRoot,
|
||||
config: 'project',
|
||||
bundler: options.bundler,
|
||||
publishable: options.publishable,
|
||||
importPath: options.importPath,
|
||||
linter: options.linter,
|
||||
unitTestRunner: options.unitTestRunner,
|
||||
useProjectJson: options.useProjectJson,
|
||||
addPlugin: options.addPlugin,
|
||||
skipFormat: true,
|
||||
useTscExecutor: true,
|
||||
})
|
||||
);
|
||||
|
||||
if (options.isTsSolutionSetup) {
|
||||
updateJson(
|
||||
host,
|
||||
joinPathFragments(options.projectRoot, 'package.json'),
|
||||
(json) => {
|
||||
// Force CommonJS for the plugin package. `jsLibraryGenerator` sets
|
||||
// `type: "module"` for TS-solution libs, but Nx-plugin generators
|
||||
// and executors that get loaded by `nx generate` / `nx run` rely on
|
||||
// CJS globals like `__dirname` (e.g. `path.join(__dirname, 'files')`
|
||||
// in generator factories). Setting `type: "commonjs"` explicitly
|
||||
// overrides Node's content-based ESM detection so the source TS
|
||||
// files load as CJS at runtime.
|
||||
json.type = 'commonjs';
|
||||
return json;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options.bundler === 'tsc') {
|
||||
tasks.push(addTsLibDependencies(host));
|
||||
}
|
||||
|
||||
tasks.push(
|
||||
addDependenciesToPackageJson(
|
||||
host,
|
||||
{
|
||||
'@nx/devkit': nxVersion,
|
||||
},
|
||||
{
|
||||
[options.unitTestRunner === 'vitest' ? '@nx/vitest' : '@nx/jest']:
|
||||
nxVersion,
|
||||
'@nx/js': nxVersion,
|
||||
'@nx/plugin': nxVersion,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Ensures Swc Deps are installed to handle running
|
||||
// local plugin generators and executors
|
||||
tasks.push(addSwcDependencies(host));
|
||||
tasks.push(addSwcRegisterDependencies(host));
|
||||
|
||||
await addFiles(host, options);
|
||||
updatePluginConfig(host, options);
|
||||
|
||||
if (options.e2eTestRunner !== 'none') {
|
||||
tasks.push(
|
||||
await e2eProjectGenerator(host, {
|
||||
pluginName: options.projectName,
|
||||
projectDirectory: options.e2eProjectDirectory,
|
||||
pluginOutputPath: joinPathFragments(
|
||||
'dist',
|
||||
options.rootProject ? options.projectName : options.projectRoot
|
||||
),
|
||||
npmPackageName: options.importPath,
|
||||
skipFormat: true,
|
||||
rootProject: options.rootProject,
|
||||
linter: options.linter,
|
||||
useProjectJson: options.useProjectJson,
|
||||
addPlugin: options.addPlugin,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (options.linter === 'eslint' && !options.skipLintChecks) {
|
||||
await pluginLintCheckGenerator(host, { projectName: options.projectName });
|
||||
}
|
||||
|
||||
if (!options.skipFormat) {
|
||||
await formatFiles(host);
|
||||
}
|
||||
|
||||
return runTasksInSerial(...tasks);
|
||||
}
|
||||
|
||||
export default pluginGenerator;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Linter, LinterType } from '@nx/eslint';
|
||||
|
||||
export interface Schema {
|
||||
directory: string;
|
||||
name?: string;
|
||||
importPath?: string;
|
||||
skipTsConfig?: boolean; // default is false
|
||||
skipFormat?: boolean; // default is false
|
||||
skipLintChecks?: boolean; // default is false
|
||||
e2eTestRunner?: 'jest' | 'none';
|
||||
e2eProjectDirectory?: string;
|
||||
tags?: string;
|
||||
unitTestRunner?: 'jest' | 'vitest' | 'none';
|
||||
linter?: Linter | LinterType;
|
||||
setParserOptionsProject?: boolean;
|
||||
compiler?: 'swc' | 'tsc';
|
||||
rootProject?: boolean;
|
||||
publishable?: boolean;
|
||||
useProjectJson?: boolean;
|
||||
addPlugin?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "NxPluginPlugin",
|
||||
"title": "Create a Plugin for Nx",
|
||||
"description": "Create a Plugin for Nx.",
|
||||
"type": "object",
|
||||
"examples": [
|
||||
{
|
||||
"command": "nx g plugin my-plugin --directory=plugins --importPath=@myorg/my-plugin",
|
||||
"description": "Generates an Nx plugin project called `plugins-my-plugin` at `libs/plugins/my-plugin`. The project will have an npm package name and import path of `@myorg/my-plugin`."
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": "A directory where the plugin is placed.",
|
||||
"$default": {
|
||||
"$source": "argv",
|
||||
"index": 0
|
||||
},
|
||||
"x-prompt": "Which directory do you want to create the plugin in?"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Plugin name",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"importPath": {
|
||||
"type": "string",
|
||||
"description": "How the plugin will be published, like `@myorg/my-awesome-plugin`. Note this must be a valid NPM name.",
|
||||
"x-priority": "important"
|
||||
},
|
||||
"linter": {
|
||||
"description": "The tool to use for running lint checks.",
|
||||
"type": "string",
|
||||
"enum": ["none", "eslint"],
|
||||
"x-priority": "important"
|
||||
},
|
||||
"unitTestRunner": {
|
||||
"description": "Test runner to use for unit tests.",
|
||||
"type": "string",
|
||||
"enum": ["none", "jest", "vitest"],
|
||||
"x-priority": "important"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"description": "Add tags to the library (used for linting).",
|
||||
"alias": "t"
|
||||
},
|
||||
"skipFormat": {
|
||||
"description": "Skip formatting files.",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"skipTsConfig": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Do not update tsconfig.json for development experience.",
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"skipLintChecks": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Do not eslint configuration for plugin json files."
|
||||
},
|
||||
"e2eTestRunner": {
|
||||
"type": "string",
|
||||
"enum": ["jest", "none"],
|
||||
"description": "Test runner to use for end to end (E2E) tests.",
|
||||
"default": "none"
|
||||
},
|
||||
"e2eProjectDirectory": {
|
||||
"type": "string",
|
||||
"description": "A directory where the plugin E2E project is placed."
|
||||
},
|
||||
"setParserOptionsProject": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not to configure the ESLint `parserOptions.project` option. We do not do this by default for lint performance reasons.",
|
||||
"default": false
|
||||
},
|
||||
"compiler": {
|
||||
"type": "string",
|
||||
"enum": ["tsc", "swc"],
|
||||
"default": "tsc",
|
||||
"description": "The compiler used by the build and test targets."
|
||||
},
|
||||
"publishable": {
|
||||
"type": "boolean",
|
||||
"description": "Generates a boilerplate for publishing the plugin to npm.",
|
||||
"default": false
|
||||
},
|
||||
"useProjectJson": {
|
||||
"type": "boolean",
|
||||
"description": "Use a `project.json` configuration file instead of inlining the Nx configuration in the `package.json` file."
|
||||
}
|
||||
},
|
||||
"required": ["directory"]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readNxJson, type Tree } from '@nx/devkit';
|
||||
import {
|
||||
determineProjectNameAndRootOptions,
|
||||
ensureRootProjectName,
|
||||
} from '@nx/devkit/internal';
|
||||
import type { LinterType } from '@nx/eslint';
|
||||
import {
|
||||
normalizeLinterOption,
|
||||
normalizeUnitTestRunnerOption,
|
||||
isUsingTsSolutionSetup,
|
||||
shouldConfigureTsSolutionSetup,
|
||||
} from '@nx/js/internal';
|
||||
import type { Schema } from '../schema';
|
||||
|
||||
export interface NormalizedSchema extends Schema {
|
||||
projectName: string;
|
||||
fileName: string;
|
||||
projectRoot: string;
|
||||
projectDirectory: string;
|
||||
e2eProjectDirectory: string;
|
||||
parsedTags: string[];
|
||||
importPath: string;
|
||||
bundler: 'swc' | 'tsc';
|
||||
publishable: boolean;
|
||||
unitTestRunner: 'jest' | 'vitest' | 'none';
|
||||
linter: LinterType;
|
||||
useProjectJson: boolean;
|
||||
addPlugin: boolean;
|
||||
isTsSolutionSetup: boolean;
|
||||
}
|
||||
|
||||
export async function normalizeOptions(
|
||||
host: Tree,
|
||||
options: Schema
|
||||
): Promise<NormalizedSchema> {
|
||||
const linter = await normalizeLinterOption(host, options.linter);
|
||||
const unitTestRunner = await normalizeUnitTestRunnerOption(
|
||||
host,
|
||||
options.unitTestRunner,
|
||||
['jest', 'vitest']
|
||||
);
|
||||
|
||||
// this helper is called before the jsLibraryGenerator is called, so, if the
|
||||
// TS solution setup is not configured, we additionally check if the TS
|
||||
// solution setup will be configured by the jsLibraryGenerator
|
||||
const isTsSolutionSetup =
|
||||
isUsingTsSolutionSetup(host) ||
|
||||
shouldConfigureTsSolutionSetup(host, options.addPlugin);
|
||||
const nxJson = readNxJson(host);
|
||||
const addPlugin =
|
||||
options.addPlugin ??
|
||||
(isTsSolutionSetup &&
|
||||
process.env.NX_ADD_PLUGINS !== 'false' &&
|
||||
nxJson.useInferencePlugins !== false);
|
||||
|
||||
await ensureRootProjectName(options, 'library');
|
||||
const { projectName, projectRoot, importPath } =
|
||||
await determineProjectNameAndRootOptions(host, {
|
||||
name: options.name,
|
||||
projectType: 'library',
|
||||
directory: options.directory,
|
||||
importPath: options.importPath,
|
||||
rootProject: options.rootProject,
|
||||
});
|
||||
options.rootProject = projectRoot === '.';
|
||||
|
||||
const projectDirectory = projectRoot;
|
||||
|
||||
const parsedTags = options.tags
|
||||
? options.tags.split(',').map((s) => s.trim())
|
||||
: [];
|
||||
|
||||
return {
|
||||
...options,
|
||||
bundler: options.compiler ?? 'tsc',
|
||||
fileName: projectName,
|
||||
projectName: isTsSolutionSetup && !options.name ? importPath : projectName,
|
||||
projectRoot,
|
||||
projectDirectory,
|
||||
e2eProjectDirectory: options.e2eProjectDirectory ?? projectDirectory,
|
||||
parsedTags,
|
||||
importPath,
|
||||
publishable: options.publishable ?? false,
|
||||
linter,
|
||||
unitTestRunner,
|
||||
// We default to generate a project.json file if the new setup is not being used
|
||||
useProjectJson: options.useProjectJson ?? !isTsSolutionSetup,
|
||||
addPlugin,
|
||||
isTsSolutionSetup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'nx/src/internal-testing-utils/mock-project-graph';
|
||||
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import { Tree, readProjectConfiguration, readJson } from '@nx/devkit';
|
||||
|
||||
import generator from './generator';
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
|
||||
describe('preset generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
|
||||
});
|
||||
|
||||
it('should create a plugin', async () => {
|
||||
await generator(tree, {
|
||||
pluginName: 'my-plugin',
|
||||
});
|
||||
const config = readProjectConfiguration(tree, 'my-plugin');
|
||||
expect(config).toBeDefined();
|
||||
const packageJson = readJson<PackageJson>(tree, 'package.json');
|
||||
expect(packageJson.dependencies).toHaveProperty('@nx/devkit');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
formatFiles,
|
||||
names,
|
||||
runTasksInSerial,
|
||||
updateJson,
|
||||
type GeneratorCallback,
|
||||
type Tree,
|
||||
} from '@nx/devkit';
|
||||
import type { PackageJson } from 'nx/src/utils/package-json';
|
||||
import { createPackageGenerator } from '../create-package/create-package';
|
||||
import { pluginGenerator } from '../plugin/plugin';
|
||||
import type {
|
||||
NormalizedPresetGeneratorOptions,
|
||||
PresetGeneratorSchema,
|
||||
} from './schema';
|
||||
|
||||
export async function presetGenerator(
|
||||
tree: Tree,
|
||||
rawOptions: PresetGeneratorSchema
|
||||
) {
|
||||
return await presetGeneratorInternal(tree, {
|
||||
addPlugin: false,
|
||||
useProjectJson: true,
|
||||
...rawOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function presetGeneratorInternal(
|
||||
tree: Tree,
|
||||
rawOptions: PresetGeneratorSchema
|
||||
) {
|
||||
const tasks: GeneratorCallback[] = [];
|
||||
const options = normalizeOptions(rawOptions);
|
||||
|
||||
const pluginTask = await pluginGenerator(tree, {
|
||||
compiler: 'tsc',
|
||||
linter: 'eslint',
|
||||
skipFormat: true,
|
||||
unitTestRunner: 'jest',
|
||||
importPath: options.pluginName,
|
||||
e2eTestRunner: 'jest',
|
||||
publishable: true,
|
||||
// when creating a CLI package, the plugin will be in the packages folder
|
||||
directory:
|
||||
options.createPackageName && options.createPackageName !== 'false'
|
||||
? `packages/${options.pluginName}`
|
||||
: options.pluginName,
|
||||
rootProject: options.createPackageName ? false : true,
|
||||
useProjectJson: options.useProjectJson,
|
||||
addPlugin: options.addPlugin,
|
||||
});
|
||||
tasks.push(pluginTask);
|
||||
|
||||
moveNxPluginToDevDeps(tree);
|
||||
|
||||
if (options.createPackageName) {
|
||||
const e2eProject = `${options.pluginName}-e2e`;
|
||||
const cliTask = await createPackageGenerator(tree, {
|
||||
directory: `packages/${options.createPackageName}`,
|
||||
name: options.createPackageName,
|
||||
e2eProject: e2eProject,
|
||||
project: options.pluginName,
|
||||
skipFormat: true,
|
||||
unitTestRunner: 'jest',
|
||||
linter: 'eslint',
|
||||
compiler: 'tsc',
|
||||
useProjectJson: options.useProjectJson,
|
||||
addPlugin: options.addPlugin,
|
||||
});
|
||||
tasks.push(cliTask);
|
||||
}
|
||||
|
||||
await formatFiles(tree);
|
||||
|
||||
return runTasksInSerial(...tasks);
|
||||
}
|
||||
|
||||
function moveNxPluginToDevDeps(tree: Tree) {
|
||||
updateJson<PackageJson>(tree, 'package.json', (json) => {
|
||||
if (json.dependencies['@nx/plugin']) {
|
||||
const nxPluginEntry = json.dependencies['@nx/plugin'];
|
||||
delete json.dependencies['@nx/plugin'];
|
||||
json.devDependencies['@nx/plugin'] = nxPluginEntry;
|
||||
}
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
options: PresetGeneratorSchema
|
||||
): NormalizedPresetGeneratorOptions {
|
||||
return {
|
||||
...options,
|
||||
pluginName: names(
|
||||
options.pluginName.includes('/')
|
||||
? options.pluginName.split('/')[1]
|
||||
: options.pluginName
|
||||
).fileName,
|
||||
createPackageName:
|
||||
options.createPackageName === 'false' // for command line in e2e, it is passed as a string
|
||||
? undefined
|
||||
: options.createPackageName,
|
||||
};
|
||||
}
|
||||
|
||||
export default presetGenerator;
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface PresetGeneratorSchema {
|
||||
pluginName: string;
|
||||
createPackageName?: string;
|
||||
useProjectJson?: boolean;
|
||||
addPlugin?: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedPresetGeneratorOptions
|
||||
extends PresetGeneratorSchema {
|
||||
createPackageName: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"cli": "nx",
|
||||
"$id": "NxPluginPreset",
|
||||
"title": "Generator ran by create-nx-plugin",
|
||||
"description": "Initializes a workspace with an nx-plugin inside of it. Use as: `create-nx-plugin` or `create-nx-workspace --preset @nx/nx-plugin`.",
|
||||
"examples": [
|
||||
{
|
||||
"command": "npx create-nx-plugin",
|
||||
"description": "Creates a new Nx workspace containing an Nx plugin."
|
||||
},
|
||||
{
|
||||
"command": "npx create-nx-workspace --preset @nx/plugin",
|
||||
"description": "Creates a new Nx workspace containing an Nx plugin."
|
||||
}
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pluginName": {
|
||||
"type": "string",
|
||||
"description": "Plugin name",
|
||||
"aliases": ["name"]
|
||||
},
|
||||
"createPackageName": {
|
||||
"type": "string",
|
||||
"description": "Name of package which creates a workspace"
|
||||
},
|
||||
"useProjectJson": {
|
||||
"type": "boolean",
|
||||
"description": "Use a `project.json` configuration file instead of inlining the Nx configuration in the `package.json` file."
|
||||
}
|
||||
},
|
||||
"required": ["pluginName"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
GeneratorsJson,
|
||||
joinPathFragments,
|
||||
readJson,
|
||||
readProjectConfiguration,
|
||||
Tree,
|
||||
} from '@nx/devkit';
|
||||
import { PackageJson } from 'nx/src/utils/package-json';
|
||||
|
||||
export function hasGenerator(
|
||||
tree: Tree,
|
||||
projectName: string,
|
||||
generatorName: string
|
||||
): boolean {
|
||||
const project = readProjectConfiguration(tree, projectName);
|
||||
const packageJson = readJson<PackageJson>(
|
||||
tree,
|
||||
joinPathFragments(project.root, 'package.json')
|
||||
);
|
||||
if (!packageJson.generators && !packageJson.schematics) {
|
||||
return false;
|
||||
}
|
||||
const generatorsPath = joinPathFragments(
|
||||
project.root,
|
||||
packageJson.generators ?? packageJson.schematics
|
||||
);
|
||||
const generatorsJson = readJson<GeneratorsJson>(tree, generatorsPath);
|
||||
return (
|
||||
(generatorsJson.generators?.[generatorName] ??
|
||||
generatorsJson.schematics?.[generatorName]) !== undefined
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readProjectConfiguration, type Tree } from '@nx/devkit';
|
||||
import { getProjectSourceRoot } from '@nx/js/internal';
|
||||
import { dirname, join, relative } from 'node:path/posix';
|
||||
|
||||
export function getArtifactMetadataDirectory(
|
||||
tree: Tree,
|
||||
projectName: string,
|
||||
sourceDirectory: string,
|
||||
isTsSolutionSetup: boolean
|
||||
): string {
|
||||
const project = readProjectConfiguration(tree, projectName);
|
||||
|
||||
if (!isTsSolutionSetup) {
|
||||
return `./${relative(project.root, sourceDirectory)}`;
|
||||
}
|
||||
|
||||
const target = Object.values(project.targets ?? {}).find(
|
||||
(t) => t.executor === '@nx/js:tsc' || t.executor === '@nx/js:swc'
|
||||
);
|
||||
|
||||
// the repo is using the new ts setup where the outputs are contained inside the project
|
||||
if (target?.executor === '@nx/js:tsc') {
|
||||
// the @nx/js:tsc executor defaults rootDir to the project root
|
||||
return `./${join(
|
||||
'dist',
|
||||
relative(target.options.rootDir ?? project.root, sourceDirectory)
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (target?.executor === '@nx/js:swc') {
|
||||
return `./${join(
|
||||
'dist',
|
||||
target.options.stripLeadingPaths
|
||||
? relative(dirname(target.options.main), sourceDirectory)
|
||||
: relative(project.root, sourceDirectory)
|
||||
)}`;
|
||||
}
|
||||
|
||||
// We generate the plugin with the executors above, so we shouldn't get here
|
||||
// unless the user manually changed the build process. In that case, we can't
|
||||
// reliably determine the output directory because it depends on the build
|
||||
// tool, so we'll just assume some defaults.
|
||||
const baseDir = getProjectSourceRoot(project, tree);
|
||||
|
||||
return `./${join('dist', relative(baseDir, sourceDirectory))}`;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { exec } from 'child_process';
|
||||
import { tmpProjPath } from './paths';
|
||||
import { detectPackageManager, getPackageManagerCommand } from '@nx/devkit';
|
||||
import { fileExists } from './utils';
|
||||
|
||||
/**
|
||||
* Run a command asynchronously inside the e2e directory.
|
||||
*
|
||||
* @param command
|
||||
* @param opts
|
||||
*/
|
||||
export function runCommandAsync(
|
||||
command: string,
|
||||
opts: { silenceError?: boolean; env?: NodeJS.ProcessEnv; cwd?: string } = {
|
||||
silenceError: false,
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
cwd: opts.cwd ?? tmpProjPath(),
|
||||
env: { ...process.env, ...opts.env },
|
||||
windowsHide: true,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
if (!opts.silenceError && err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a nx command asynchronously inside the e2e directory
|
||||
* @param command
|
||||
* @param opts
|
||||
*/
|
||||
export function runNxCommandAsync(
|
||||
command: string,
|
||||
opts: { silenceError?: boolean; env?: NodeJS.ProcessEnv; cwd?: string } = {
|
||||
silenceError: false,
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const cwd = opts.cwd ?? tmpProjPath();
|
||||
if (fileExists(tmpProjPath('package.json'))) {
|
||||
const pmc = getPackageManagerCommand(detectPackageManager(cwd));
|
||||
return runCommandAsync(`${pmc.exec} nx ${command}`, opts);
|
||||
} else if (process.platform === 'win32') {
|
||||
return runCommandAsync(`./nx.bat %${command}`, opts);
|
||||
} else {
|
||||
return runCommandAsync(`./nx %${command}`, opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ExecSyncOptions, execSync } from 'child_process';
|
||||
import { tmpProjPath } from './paths';
|
||||
import { detectPackageManager, getPackageManagerCommand } from '@nx/devkit';
|
||||
import { fileExists } from './utils';
|
||||
|
||||
/**
|
||||
* Run a nx command inside the e2e directory
|
||||
* @param command
|
||||
* @param opts
|
||||
*
|
||||
* @see tmpProjPath
|
||||
*/
|
||||
export function runNxCommand(
|
||||
command?: string,
|
||||
opts: { silenceError?: boolean; env?: NodeJS.ProcessEnv; cwd?: string } = {
|
||||
silenceError: false,
|
||||
}
|
||||
): string {
|
||||
function _runNxCommand(c) {
|
||||
const cwd = opts.cwd ?? tmpProjPath();
|
||||
const execSyncOptions: ExecSyncOptions = {
|
||||
cwd,
|
||||
env: { ...process.env, ...opts.env },
|
||||
windowsHide: true,
|
||||
};
|
||||
if (fileExists(tmpProjPath('package.json'))) {
|
||||
const pmc = getPackageManagerCommand(detectPackageManager(cwd));
|
||||
return execSync(`${pmc.exec} nx ${command}`, execSyncOptions);
|
||||
} else if (process.platform === 'win32') {
|
||||
return execSync(`./nx.bat %${command}`, execSyncOptions);
|
||||
} else {
|
||||
return execSync(`./nx %${command}`, execSyncOptions);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return _runNxCommand(command)
|
||||
.toString()
|
||||
.replace(
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
||||
''
|
||||
);
|
||||
} catch (e) {
|
||||
if (opts.silenceError) {
|
||||
return e.stdout.toString();
|
||||
} else {
|
||||
console.log(e.stdout.toString(), e.stderr.toString());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
command: string,
|
||||
opts: { env?: NodeJS.ProcessEnv; cwd?: string }
|
||||
): string {
|
||||
try {
|
||||
return execSync(command, {
|
||||
cwd: opts.cwd ?? tmpProjPath(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...opts?.env },
|
||||
windowsHide: true,
|
||||
}).toString();
|
||||
} catch (e) {
|
||||
return e.stdout.toString() + e.stderr.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './async-commands';
|
||||
export * from './commands';
|
||||
export * from './paths';
|
||||
export * from './nx-project';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,90 @@
|
||||
import { detectPackageManager, workspaceRoot } from '@nx/devkit';
|
||||
import {
|
||||
getPackageManagerCommand,
|
||||
readJsonFile,
|
||||
writeJsonFile,
|
||||
} from '@nx/devkit';
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'path';
|
||||
import { tmpProjPath } from './paths';
|
||||
import { cleanup } from './utils';
|
||||
|
||||
function runNxNewCommand(args?: string, silent?: boolean) {
|
||||
const localTmpDir = dirname(tmpProjPath());
|
||||
return execSync(
|
||||
`node ${require.resolve(
|
||||
'nx'
|
||||
)} new proj --nx-workspace-root=${localTmpDir} --no-interactive --skip-install --collection=@nx/workspace --npmScope=proj --preset=apps ${
|
||||
args || ''
|
||||
}`,
|
||||
{
|
||||
cwd: localTmpDir,
|
||||
...(silent && false ? { stdio: ['ignore', 'ignore', 'ignore'] } : {}),
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function patchPackageJsonForPlugin(
|
||||
npmPackageName: string,
|
||||
distPath: string
|
||||
) {
|
||||
const path = tmpProjPath('package.json');
|
||||
const json = readJsonFile(path);
|
||||
json.devDependencies[npmPackageName] = `file:${workspaceRoot}/${distPath}`;
|
||||
writeJsonFile(path, json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique name for running CLI commands
|
||||
* @param prefix
|
||||
*
|
||||
* @returns `'<prefix><random number>'`
|
||||
*/
|
||||
export function uniq(prefix: string) {
|
||||
return `${prefix}${Math.floor(Math.random() * 10000000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the appropriate package manager install command in the e2e directory
|
||||
* @param silent silent output from the install
|
||||
*/
|
||||
export function runPackageManagerInstall(silent: boolean = true) {
|
||||
const cwd = tmpProjPath();
|
||||
const pmc = getPackageManagerCommand(detectPackageManager(cwd));
|
||||
const install = execSync(pmc.install, {
|
||||
cwd,
|
||||
...(silent ? { stdio: ['ignore', 'ignore', 'ignore'] } : {}),
|
||||
windowsHide: true,
|
||||
});
|
||||
return install ? install.toString() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new nx project in the e2e directory
|
||||
*
|
||||
* @param npmPackageName package name to test
|
||||
* @param pluginDistPath dist path where the plugin was outputted to
|
||||
*/
|
||||
export function newNxProject(
|
||||
npmPackageName: string,
|
||||
pluginDistPath: string
|
||||
): void {
|
||||
cleanup();
|
||||
runNxNewCommand('', true);
|
||||
patchPackageJsonForPlugin(npmPackageName, pluginDistPath);
|
||||
runPackageManagerInstall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a project has been setup in the e2e directory
|
||||
* It will also copy `@nx` packages to the e2e directory
|
||||
*/
|
||||
export function ensureNxProject(
|
||||
npmPackageName?: string,
|
||||
pluginDistPath?: string
|
||||
): void {
|
||||
mkdirSync(tmpProjPath(), { recursive: true });
|
||||
newNxProject(npmPackageName, pluginDistPath);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
|
||||
export function tmpFolder() {
|
||||
return `${workspaceRoot}/tmp`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory where the e2e workspace resides in.
|
||||
*
|
||||
* @param path path within the e2e directory
|
||||
* @returns `'${process.cwd()}/tmp/nx-e2e/proj/<path>'`
|
||||
*/
|
||||
export function tmpProjPath(path?: string) {
|
||||
return path
|
||||
? `${tmpFolder()}/nx-e2e/proj/${path}`
|
||||
: `${tmpFolder()}/nx-e2e/proj`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace backup directory. This is used for caching of the creation of the workspace.
|
||||
*
|
||||
* @param path path within the e2e directory
|
||||
* @returns `'${process.cwd()}/tmp/nx-e2e/proj-backup/<path>'`
|
||||
*/
|
||||
export function tmpBackupProjPath(path?: string) {
|
||||
return path
|
||||
? `${workspaceRoot}/tmp/nx-e2e/proj-backup/${path}`
|
||||
: `${workspaceRoot}/tmp/nx-e2e/proj-backup`;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute } from 'path';
|
||||
import { tmpFolder, tmpProjPath } from './paths';
|
||||
import { parseJson } from '@nx/devkit';
|
||||
import type { JsonParseOptions } from '@nx/devkit';
|
||||
import { directoryExists, fileExists } from 'nx/src/utils/fileutils';
|
||||
|
||||
export { directoryExists, fileExists };
|
||||
|
||||
/**
|
||||
* Copies module folders from the working directory to the e2e directory
|
||||
* @param modules a list of module names or scopes to copy
|
||||
*/
|
||||
export function copyNodeModules(modules: string[]) {
|
||||
modules.forEach((module) => {
|
||||
rmSync(`${tmpProjPath()}/node_modules/${module}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
cpSync(
|
||||
`./node_modules/${module}`,
|
||||
`${tmpProjPath()}/node_modules/${module}`,
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert test output from a asynchronous CLI command.
|
||||
*
|
||||
* @param output Output from an asynchronous command
|
||||
*/
|
||||
export function expectTestsPass(output: { stdout: string; stderr: string }) {
|
||||
expect(output.stderr).toContain('Ran all test suites');
|
||||
expect(output.stderr).not.toContain('fail');
|
||||
}
|
||||
|
||||
// type callback =
|
||||
|
||||
/**
|
||||
* Update a file's content in the e2e directory.
|
||||
*
|
||||
* If the `content` param is a callback, it will provide the original file content as an argument.
|
||||
*
|
||||
* @param file Path of the file in the e2e directory
|
||||
* @param content Content to replace the original content with
|
||||
*/
|
||||
export function updateFile(
|
||||
file: string,
|
||||
content: string | ((originalFileContent: string) => string)
|
||||
): void {
|
||||
mkdirSync(dirname(tmpProjPath(file)), { recursive: true });
|
||||
if (typeof content === 'string') {
|
||||
writeFileSync(tmpProjPath(file), content);
|
||||
} else {
|
||||
writeFileSync(
|
||||
tmpProjPath(file),
|
||||
content(readFileSync(tmpProjPath(file)).toString())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file or directory within the e2e directory.
|
||||
* @param path Original path
|
||||
* @param newPath New path
|
||||
*/
|
||||
export function renameFile(path: string, newPath: string): void {
|
||||
mkdirSync(dirname(tmpProjPath(newPath)), { recursive: true });
|
||||
renameSync(tmpProjPath(path), tmpProjPath(newPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file or directory exists.
|
||||
*
|
||||
* If a path starts with `/` or `C:/`, it will check it as absolute. Otherwise it will check within the e2e directory.
|
||||
*
|
||||
* @param expectedPaths Files or directories to check
|
||||
* @usage `checkFileExists('file1', 'file2', '/var/user/file')`
|
||||
*/
|
||||
export function checkFilesExist(...expectedPaths: string[]) {
|
||||
expectedPaths.forEach((path) => {
|
||||
const filePath = isAbsolute(path) ? path : tmpProjPath(path);
|
||||
if (!exists(filePath)) {
|
||||
throw new Error(`'${filePath}' does not exist`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all files within a directory.
|
||||
* @param dirName Directory name within the e2e directory.
|
||||
*/
|
||||
export function listFiles(dirName: string): string[] {
|
||||
return readdirSync(tmpProjPath(dirName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON file.
|
||||
* @param path Path to the JSON file. Absolute or relative to the e2e directory.
|
||||
* @param options JSON parse options
|
||||
*/
|
||||
export function readJson<T extends object = any>(
|
||||
path: string,
|
||||
options?: JsonParseOptions
|
||||
): T {
|
||||
return parseJson<T>(readFile(path), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file.
|
||||
* @param path Path to the file. Absolute or relative to the e2e directory.
|
||||
*/
|
||||
export function readFile(path: string): string {
|
||||
const filePath = isAbsolute(path) ? path : tmpProjPath(path);
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the e2e directory
|
||||
*/
|
||||
export function cleanup(): void {
|
||||
rmSync(tmpProjPath(), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the dist folder from the e2e directory
|
||||
*/
|
||||
export function rmDist(): void {
|
||||
rmSync(`${tmpProjPath()}/dist`, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function removeTmpProject(project: string): void {
|
||||
rmSync(`${tmpFolder()}/${project}`, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currend `cwd` in the process
|
||||
*/
|
||||
export function getCwd(): string {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file or directory exists.
|
||||
* @param path Path to file or directory
|
||||
*/
|
||||
export function exists(path: string): boolean {
|
||||
return directoryExists(path) || fileExists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a file on disk
|
||||
* @param filePath Path to the file
|
||||
*/
|
||||
export function getSize(filePath: string): number {
|
||||
return statSync(filePath).size;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { join } from 'path';
|
||||
|
||||
export const nxVersion = require(join('@nx/plugin', 'package.json')).version;
|
||||
|
||||
export const jsoncEslintParserVersion = '^2.1.0';
|
||||
Reference in New Issue
Block a user