From 7f7084f1c4809acf9278a8dafbf255ba34c5ab0b Mon Sep 17 00:00:00 2001 From: Ben Callaghan <44448874+bcallaghan-et@users.noreply.github.com> Date: Mon, 5 Jul 2021 10:27:53 -0600 Subject: [PATCH] feat(core): add test project generator (#69) * feat(core): add test project generator Add a generator that creates test projects for existing apps/libs Co-authored-by: Ben Callaghan --- packages/core/generators.json | 5 + .../src/generators/test/generator.spec.ts | 44 +++++ .../core/src/generators/test/generator.ts | 14 ++ packages/core/src/generators/test/schema.d.ts | 3 + packages/core/src/generators/test/schema.json | 48 +++++ .../generators/utils/generate-project.spec.ts | 13 -- .../src/generators/utils/generate-project.ts | 94 +++------ .../utils/generate-test-project.spec.ts | 182 ++++++++++++++++++ .../generators/utils/generate-test-project.ts | 69 +++++++ packages/core/src/models/index.ts | 1 + .../core/src/models/test-generator-schema.ts | 6 + 11 files changed, 402 insertions(+), 77 deletions(-) create mode 100644 packages/core/src/generators/test/generator.spec.ts create mode 100644 packages/core/src/generators/test/generator.ts create mode 100644 packages/core/src/generators/test/schema.d.ts create mode 100644 packages/core/src/generators/test/schema.json create mode 100644 packages/core/src/generators/utils/generate-test-project.spec.ts create mode 100644 packages/core/src/generators/utils/generate-test-project.ts create mode 100644 packages/core/src/models/test-generator-schema.ts diff --git a/packages/core/generators.json b/packages/core/generators.json index 23691a11..2b498f53 100644 --- a/packages/core/generators.json +++ b/packages/core/generators.json @@ -39,6 +39,11 @@ "factory": "./src/generators/restore/generator", "schema": "./src/generators/restore/schema.json", "description": "Restores NuGet packages and .NET tools used by the workspace" + }, + "test": { + "factory": "./src/generators/test/generator", + "schema": "./src/generators/test/schema.json", + "description": "Generate a .NET test project for an existing application or library" } } } diff --git a/packages/core/src/generators/test/generator.spec.ts b/packages/core/src/generators/test/generator.spec.ts new file mode 100644 index 00000000..165c144c --- /dev/null +++ b/packages/core/src/generators/test/generator.spec.ts @@ -0,0 +1,44 @@ +import { Tree } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; + +import { DotNetClient, mockDotnetFactory } from '@nx-dotnet/dotnet'; + +import * as mockedProjectGenerator from '../utils/generate-test-project'; +import generator from './generator'; +import { NxDotnetGeneratorSchema } from './schema'; + +jest.mock('../utils/generate-test-project'); + +describe('nx-dotnet test generator', () => { + let appTree: Tree; + let dotnetClient: DotNetClient; + + const options: NxDotnetGeneratorSchema = { + project: 'existing', + testTemplate: 'xunit', + language: 'C#', + skipOutputPathManipulation: true, + }; + + beforeEach(() => { + appTree = createTreeWithEmptyWorkspace(); + dotnetClient = new DotNetClient(mockDotnetFactory()); + }); + + it('should run successfully', async () => { + await generator(appTree, options, dotnetClient); + }); + + it('should call project generator with application project type', async () => { + const projectGenerator = (mockedProjectGenerator as jest.Mocked< + typeof mockedProjectGenerator + >).GenerateTestProject; + + await generator(appTree, options, dotnetClient); + expect(projectGenerator).toHaveBeenCalledWith( + appTree, + options, + dotnetClient, + ); + }); +}); diff --git a/packages/core/src/generators/test/generator.ts b/packages/core/src/generators/test/generator.ts new file mode 100644 index 00000000..034bfa6b --- /dev/null +++ b/packages/core/src/generators/test/generator.ts @@ -0,0 +1,14 @@ +import { Tree } from '@nrwl/devkit'; + +import { DotNetClient, dotnetFactory } from '@nx-dotnet/dotnet'; + +import { GenerateTestProject } from '../utils/generate-test-project'; +import { NxDotnetGeneratorSchema } from './schema'; + +export default function ( + host: Tree, + options: NxDotnetGeneratorSchema, + dotnetClient = new DotNetClient(dotnetFactory()), +) { + return GenerateTestProject(host, options, dotnetClient); +} diff --git a/packages/core/src/generators/test/schema.d.ts b/packages/core/src/generators/test/schema.d.ts new file mode 100644 index 00000000..7c0b6950 --- /dev/null +++ b/packages/core/src/generators/test/schema.d.ts @@ -0,0 +1,3 @@ +import { NxDotnetTestGeneratorSchema } from '../../models'; + +export type NxDotnetGeneratorSchema = NxDotnetTestGeneratorSchema; diff --git a/packages/core/src/generators/test/schema.json b/packages/core/src/generators/test/schema.json new file mode 100644 index 00000000..fff8f424 --- /dev/null +++ b/packages/core/src/generators/test/schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "id": "@nx-dotnet/core:lib", + "title": "NxDotnet Test Generator", + "description": "Generate a .NET test project for an existing application or library", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The existing project to generate tests for", + "$default": { + "$source": "argv", + "index": 0 + } + }, + "testTemplate": { + "type": "string", + "description": "Which template should be used for creating the tests project?", + "default": "nunit", + "enum": ["nunit", "xunit", "mstest"], + "x-prompt": { + "message": "Which template should be used for creating the tests project", + "type": "list", + "items": [ + { "value": "nunit", "label": "NUnit 3 Test Project" }, + { "value": "xunit", "label": "xUnit Test Project" }, + { "value": "mstest", "label": "Unit Test Project" } + ] + } + }, + "language": { + "type": "string", + "description": "Which language should the project use?", + "x-prompt": { + "message": "Which language should the project use?", + "type": "list", + "items": ["C#", "F#", "VB"] + } + }, + "skipOutputPathManipulation": { + "type": "boolean", + "description": "Skip XML changes for default build path", + "default": false + } + }, + "required": ["name", "testTemplate"] +} diff --git a/packages/core/src/generators/utils/generate-project.spec.ts b/packages/core/src/generators/utils/generate-project.spec.ts index fcd3a370..80911690 100644 --- a/packages/core/src/generators/utils/generate-project.spec.ts +++ b/packages/core/src/generators/utils/generate-project.spec.ts @@ -59,12 +59,6 @@ describe('nx-dotnet project generator', () => { expect(config.targets.serve).not.toBeDefined(); }); - it('should tag generated projects', async () => { - await GenerateProject(appTree, options, dotnetClient, 'library'); - const config = readProjectConfiguration(appTree, 'test'); - expect(config.tags).toContain('nx-dotnet'); - }); - it('should run successfully for applications', async () => { await GenerateProject(appTree, options, dotnetClient, 'application'); const config = readProjectConfiguration(appTree, 'test'); @@ -102,13 +96,6 @@ describe('nx-dotnet project generator', () => { expect(config.targets.lint).toBeDefined(); }); - it('should include lint target in test project', async () => { - options.testTemplate = 'nunit'; - await GenerateProject(appTree, options, dotnetClient, 'application'); - const config = readProjectConfiguration(appTree, 'test-test'); - expect(config.targets.lint).toBeDefined(); - }); - it('should prepend directory name to project name', async () => { options.directory = 'sub-dir'; const spy = spyOn(dotnetClient, 'new'); diff --git a/packages/core/src/generators/utils/generate-project.ts b/packages/core/src/generators/utils/generate-project.ts index ac1b8417..09d76b6f 100644 --- a/packages/core/src/generators/utils/generate-project.ts +++ b/packages/core/src/generators/utils/generate-project.ts @@ -6,6 +6,7 @@ import { NxJsonProjectConfiguration, ProjectConfiguration, ProjectType, + readProjectConfiguration, readWorkspaceConfiguration, Tree, } from '@nrwl/devkit'; @@ -25,12 +26,13 @@ import { GetBuildExecutorConfiguration, GetLintExecutorConfiguration, GetServeExecutorConfig, - GetTestExecutorConfig, NxDotnetProjectGeneratorSchema, + NxDotnetTestGeneratorSchema, } from '../../models'; import initSchematic from '../init/generator'; +import { GenerateTestProject } from './generate-test-project'; -interface NormalizedSchema extends NxDotnetProjectGeneratorSchema { +export interface NormalizedSchema extends NxDotnetProjectGeneratorSchema { projectName: string; projectRoot: string; projectDirectory: string; @@ -39,13 +41,32 @@ interface NormalizedSchema extends NxDotnetProjectGeneratorSchema { parsedTags: string[]; className: string; namespaceName: string; + projectType: ProjectType; } -function normalizeOptions( +export function normalizeOptions( host: Tree, - options: NxDotnetProjectGeneratorSchema, - projectType: ProjectType, + options: NxDotnetProjectGeneratorSchema | NxDotnetTestGeneratorSchema, + projectType?: ProjectType, ): NormalizedSchema { + if (!('name' in options)) { + // Reconstruct the original parameters as if the test project were generated at the same time as the target project. + const project = readProjectConfiguration(host, options.project); + const projectPaths = project.root.split('/'); + const directory = projectPaths.slice(1, -1).join('/'); // The middle portions contain the original path. + const [name] = projectPaths.slice(-1); // The final folder contains the original name. + + options = { + name, + language: options.language, + skipOutputPathManipulation: options.skipOutputPathManipulation, + testTemplate: options.testTemplate, + directory, + tags: project.tags?.join(','), + } as NxDotnetProjectGeneratorSchema; + projectType = project.projectType; + } + const name = names(options.name).fileName; const className = names(options.name).className; const projectDirectory = options.directory @@ -79,61 +100,11 @@ function normalizeOptions( projectLanguage: options.language, projectTemplate: options.template, namespaceName, + projectType: projectType ?? 'library', }; } -async function GenerateTestProject( - schema: NormalizedSchema, - host: Tree, - dotnetClient: DotNetClient, - projectType: ProjectType, -) { - const testRoot = schema.projectRoot + '-test'; - const testProjectName = schema.projectName + '-test'; - - addProjectConfiguration(host, testProjectName, { - root: testRoot, - projectType: projectType, - sourceRoot: `${testRoot}`, - targets: { - build: GetBuildExecutorConfiguration(testRoot), - test: GetTestExecutorConfig(), - lint: GetLintExecutorConfiguration(), - }, - tags: schema.parsedTags, - }); - - const newParams: dotnetNewOptions = [ - { - flag: 'language', - value: schema.language, - }, - { - flag: 'name', - value: schema.namespaceName + '.Test', - }, - { - flag: 'output', - value: schema.projectRoot + '-test', - }, - ]; - - if (isDryRun()) { - addDryRunParameter(newParams); - } - - dotnetClient.new(schema.testTemplate, newParams); - - if (!isDryRun() && !schema.skipOutputPathManipulation) { - const testCsProj = await findProjectFileInPath(testRoot); - SetOutputPath(host, testRoot, testCsProj); - const baseCsProj = await findProjectFileInPath(schema.projectRoot); - SetOutputPath(host, schema.projectRoot, baseCsProj); - dotnetClient.addProjectReference(testCsProj, baseCsProj); - } -} - -function SetOutputPath( +export function SetOutputPath( host: Tree, projectRootPath: string, projectFilePath: string, @@ -227,12 +198,7 @@ export async function GenerateProject( dotnetClient.new(normalizedOptions.template, newParams); if (options['testTemplate'] !== 'none') { - await GenerateTestProject( - normalizedOptions, - host, - dotnetClient, - projectType, - ); + await GenerateTestProject(host, normalizedOptions, dotnetClient); } else if (!options.skipOutputPathManipulation) { SetOutputPath( host, @@ -244,7 +210,7 @@ export async function GenerateProject( await formatFiles(host); } -function addDryRunParameter(parameters: dotnetNewOptions): void { +export function addDryRunParameter(parameters: dotnetNewOptions): void { parameters.push({ flag: 'dryRun', value: true, diff --git a/packages/core/src/generators/utils/generate-test-project.spec.ts b/packages/core/src/generators/utils/generate-test-project.spec.ts new file mode 100644 index 00000000..a2e25595 --- /dev/null +++ b/packages/core/src/generators/utils/generate-test-project.spec.ts @@ -0,0 +1,182 @@ +import { + addProjectConfiguration, + readProjectConfiguration, + Tree, + writeJson, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; + +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { XmlDocument } from 'xmldoc'; + +import { + DotNetClient, + dotnetFactory, + dotnetNewOptions, + mockDotnetFactory, +} from '@nx-dotnet/dotnet'; +import { findProjectFileInPath, NXDOTNET_TAG, rimraf } from '@nx-dotnet/utils'; + +import { NxDotnetTestGeneratorSchema } from '../../models'; +import { GenerateTestProject } from './generate-test-project'; + +describe('nx-dotnet test project generator', () => { + let appTree: Tree; + let dotnetClient: DotNetClient; + let options: NxDotnetTestGeneratorSchema; + let testProjectName: string; + + beforeEach(() => { + appTree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(appTree, 'domain-existing-app', { + root: 'apps/domain/existing-app', + projectType: 'application', + targets: {}, + }); + addProjectConfiguration(appTree, 'domain-existing-lib', { + root: 'libs/domain/existing-lib', + projectType: 'library', + targets: {}, + }); + + mkdirSync('apps/domain/existing-app', { recursive: true }); + writeFileSync( + 'apps/domain/existing-app/Proj.Domain.ExistingApp.csproj', + ` + + net5.0 + +`, + ); + + dotnetClient = new DotNetClient(mockDotnetFactory()); + + const packageJson = { scripts: {} }; + writeJson(appTree, 'package.json', packageJson); + + options = { + project: 'domain-existing-app', + testTemplate: 'xunit', + language: 'C#', + skipOutputPathManipulation: true, + }; + testProjectName = options.project + '-test'; + }); + + afterEach(async () => { + await Promise.all([rimraf('apps'), rimraf('libs'), rimraf('.config')]); + }); + + it('should detect library type for libraries', async () => { + options.project = 'domain-existing-lib'; + testProjectName = options.project + '-test'; + await GenerateTestProject(appTree, options, dotnetClient); + const config = readProjectConfiguration(appTree, testProjectName); + expect(config.projectType).toBe('library'); + }); + + it('should tag nx-dotnet projects', async () => { + await GenerateTestProject(appTree, options, dotnetClient); + const config = readProjectConfiguration(appTree, testProjectName); + expect(config.tags).toContain(NXDOTNET_TAG); + }); + + it('should detect application type for applications', async () => { + await GenerateTestProject(appTree, options, dotnetClient); + const config = readProjectConfiguration(appTree, testProjectName); + expect(config.projectType).toBe('application'); + }); + + it('should include test target', async () => { + await GenerateTestProject(appTree, options, dotnetClient); + const config = readProjectConfiguration(appTree, testProjectName); + expect(config.targets.test).toBeDefined(); + }); + + it('should set output paths in build target', async () => { + await GenerateTestProject(appTree, options, dotnetClient); + const config = readProjectConfiguration(appTree, testProjectName); + const outputPath = config.targets.build.options.output; + expect(outputPath).toBeTruthy(); + + const absoluteDistPath = resolve(appTree.root, outputPath); + const expectedDistPath = resolve( + appTree.root, + './dist/apps/domain/existing-app-test', + ); + + expect(absoluteDistPath).toEqual(expectedDistPath); + }); + + it('should include lint target', async () => { + await GenerateTestProject(appTree, options, dotnetClient); + const config = readProjectConfiguration(appTree, testProjectName); + expect(config.targets.lint).toBeDefined(); + }); + + it('should determine directory from existing project', async () => { + await GenerateTestProject(appTree, options, dotnetClient); + const config = readProjectConfiguration(appTree, testProjectName); + expect(config.root).toBe('apps/domain/existing-app-test'); + }); + + it('should prepend directory name to project name', async () => { + const spy = spyOn(dotnetClient, 'new'); + await GenerateTestProject(appTree, options, dotnetClient); + const dotnetOptions: dotnetNewOptions = spy.calls.mostRecent().args[1]; + const nameFlag = dotnetOptions.find((flag) => flag.flag === 'name'); + expect(nameFlag?.value).toBe('Proj.Domain.ExistingApp.Test'); + }); + + /** + * This test requires a live dotnet client. + */ + it('should add a reference to the target project', async () => { + await GenerateTestProject( + appTree, + { + ...options, + skipOutputPathManipulation: false, + }, + new DotNetClient(dotnetFactory()), + ); + const config = readProjectConfiguration(appTree, testProjectName); + const projectFilePath = await findProjectFileInPath(config.root); + const projectXml = new XmlDocument( + readFileSync(projectFilePath).toString(), + ); + const projectReference = projectXml + .childrenNamed('ItemGroup')[1] + ?.childNamed('ProjectReference'); + expect(projectReference).toBeDefined(); + }); + + /** + * This test requires a live dotnet client. + */ + it('should update output paths in project file', async () => { + await GenerateTestProject( + appTree, + { + ...options, + skipOutputPathManipulation: false, + }, + new DotNetClient(dotnetFactory()), + ); + const config = readProjectConfiguration(appTree, testProjectName); + const projectFilePath = await findProjectFileInPath(config.root); + const projectXml = new XmlDocument( + readFileSync(projectFilePath).toString(), + ); + const outputPath = projectXml + .childNamed('PropertyGroup') + ?.childNamed('OutputPath')?.val as string; + expect(outputPath).toBeTruthy(); + + const absoluteDistPath = resolve(config.root, outputPath); + const expectedDistPath = resolve('./dist/apps/domain/existing-app-test'); + + expect(absoluteDistPath).toEqual(expectedDistPath); + }); +}); diff --git a/packages/core/src/generators/utils/generate-test-project.ts b/packages/core/src/generators/utils/generate-test-project.ts new file mode 100644 index 00000000..48b928e2 --- /dev/null +++ b/packages/core/src/generators/utils/generate-test-project.ts @@ -0,0 +1,69 @@ +import { addProjectConfiguration, Tree } from '@nrwl/devkit'; +import { DotNetClient, dotnetNewOptions } from '@nx-dotnet/dotnet'; +import { findProjectFileInPath, isDryRun } from '@nx-dotnet/utils'; +import { + GetBuildExecutorConfiguration, + GetLintExecutorConfiguration, + GetTestExecutorConfig, + NxDotnetTestGeneratorSchema, +} from '../../models'; +import { + NormalizedSchema, + normalizeOptions, + addDryRunParameter, + SetOutputPath, +} from './generate-project'; + +export async function GenerateTestProject( + host: Tree, + schema: NxDotnetTestGeneratorSchema | NormalizedSchema, + dotnetClient: DotNetClient, +) { + if (!('projectRoot' in schema)) { + schema = normalizeOptions(host, schema); + } + + const testRoot = schema.projectRoot + '-test'; + const testProjectName = schema.projectName + '-test'; + + addProjectConfiguration(host, testProjectName, { + root: testRoot, + projectType: schema.projectType, + sourceRoot: `${testRoot}`, + targets: { + build: GetBuildExecutorConfiguration(testRoot), + test: GetTestExecutorConfig(), + lint: GetLintExecutorConfiguration(), + }, + tags: schema.parsedTags, + }); + + const newParams: dotnetNewOptions = [ + { + flag: 'language', + value: schema.language, + }, + { + flag: 'name', + value: schema.namespaceName + '.Test', + }, + { + flag: 'output', + value: schema.projectRoot + '-test', + }, + ]; + + if (isDryRun()) { + addDryRunParameter(newParams); + } + + dotnetClient.new(schema.testTemplate, newParams); + + if (!isDryRun() && !schema.skipOutputPathManipulation) { + const testCsProj = await findProjectFileInPath(testRoot); + SetOutputPath(host, testRoot, testCsProj); + const baseCsProj = await findProjectFileInPath(schema.projectRoot); + SetOutputPath(host, schema.projectRoot, baseCsProj); + dotnetClient.addProjectReference(testCsProj, baseCsProj); + } +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 4deae916..62978e06 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -3,3 +3,4 @@ export * from './project-generator-schema'; export * from './serve-executor-configuration'; export * from './test-executor-configuration'; export * from './lint-executor-configuration'; +export * from './test-generator-schema'; diff --git a/packages/core/src/models/test-generator-schema.ts b/packages/core/src/models/test-generator-schema.ts new file mode 100644 index 00000000..a6ccf7de --- /dev/null +++ b/packages/core/src/models/test-generator-schema.ts @@ -0,0 +1,6 @@ +export interface NxDotnetTestGeneratorSchema { + project: string; + testTemplate: 'xunit' | 'nunit' | 'mstest'; + language: string; + skipOutputPathManipulation: boolean; +}