Skip to content
Draft
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5da3e11
feat(plugin): add vitest support for e2e tests
Copilot Jan 7, 2026
6bd21d6
test(plugin): add tests for vitest e2e support
Copilot Jan 7, 2026
8a9fa56
chore(plugin): remove unused import
Copilot Jan 7, 2026
144234d
fix(plugin): handle both .ts and .mts vitest config extensions
Copilot Jan 7, 2026
ac78f43
docs(plugin): add vitest example to e2e-project generator
Copilot Jan 7, 2026
d8f573b
fix(plugin): address code review feedback
Copilot Jan 7, 2026
9015c90
fix(plugin): improve vitest config modification
Copilot Jan 7, 2026
4c9e6a5
fix(plugin): use ensurePackage for vitest and remove explicit coverag…
Copilot Jan 7, 2026
7092c91
fix(plugin): remove extra closing brace causing syntax error
Copilot Jan 7, 2026
c246768
chore(plugin): format vitest e2e files
nx-cloud[bot] Jan 7, 2026
8737919
test(plugin): add e2e test for vitest-backed plugin
Copilot Jan 7, 2026
511f523
fix(plugin): add missing closing brace and tasks declaration
nx-cloud[bot] Jan 7, 2026
a4cd531
fix(plugin): add @nx/vitest as optional peer dependency
Copilot Jan 7, 2026
7fe23b8
chore(nx-plugin): fixup code
AgentEnder Jan 9, 2026
805ad3e
chore(nx-plugin): fixup code [Self-Healing CI Rerun]
nx-cloud[bot] Apr 2, 2026
785bcf0
chore(nx-plugin): fixup code [Self-Healing CI Rerun]
nx-cloud[bot] Apr 2, 2026
062cf10
chore(nx-plugin): fixup code [Self-Healing CI Rerun]
nx-cloud[bot] Apr 2, 2026
68f0e3f
Merge branch 'master' into copilot/add-vitest-support-e2e
AgentEnder Apr 8, 2026
32ed73e
Merge branch 'master' into copilot/add-vitest-support-e2e [Self-Heali…
nx-cloud[bot] Apr 9, 2026
92f6dab
Merge branch 'master' into copilot/add-vitest-support-e2e [Self-Heali…
nx-cloud[bot] Apr 9, 2026
14d9ad7
Merge branch 'master' into copilot/add-vitest-support-e2e [Self-Heali…
nx-cloud[bot] Apr 9, 2026
b797bda
Merge branch 'master' into copilot/add-vitest-support-e2e [Self-Heali…
nx-cloud[bot] Apr 9, 2026
6a6b249
Merge branch 'master' into copilot/add-vitest-support-e2e [Self-Heali…
nx-cloud[bot] Apr 9, 2026
4c65167
Merge branch 'master' into copilot/add-vitest-support-e2e
AgentEnder Apr 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions e2e/plugin/src/nx-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ProjectConfiguration } from '@nx/devkit';
import {
checkFilesExist,
checkFilesMatchingPatternExist,
cleanupProject,
createFile,
expectTestsPass,
Expand Down Expand Up @@ -54,6 +55,31 @@ describe('Nx Plugin', () => {
runCLI(`e2e ${plugin}-e2e`);
}, 90000);

it('should be able to generate a Nx Plugin with vitest e2e tests', async () => {
const plugin = uniq('plugin');

runCLI(
`generate @nx/plugin:plugin ${plugin} --linter=eslint --e2eTestRunner=vitest --publishable`
);
const lintResults = runCLI(`lint ${plugin}`);
expect(lintResults).toContain('All files pass linting');

const buildResults = runCLI(`build ${plugin}`);
expect(buildResults).toContain('Done compiling TypeScript files');
checkFilesExist(
`dist/${plugin}/package.json`,
`dist/${plugin}/src/index.js`
);

// Verify vitest config was created
checkFilesMatchingPatternExist(`${plugin}-e2e/vitest.config.(ts|mts)`);

// Run the e2e tests with vitest
expect(() => {
runCLI(`e2e ${plugin}-e2e`);
}).not.toThrow();
}, 120000);

it('should be able to generate a migration', async () => {
const plugin = uniq('plugin');
const version = '1.0.0';
Expand Down
12 changes: 10 additions & 2 deletions packages/plugin/docs/generators/e2e-project-examples.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
## Examples

##### E2E Project
##### E2E Project with Jest (default)

Scaffolds an E2E project for the plugin `my-plugin`.
Scaffolds an E2E project for the plugin `my-plugin` using Jest.

```bash
nx g @nx/plugin:e2e-project --pluginName my-plugin --npmPackageName my-plugin --pluginOutputPath dist/my-plugin
```

##### E2E Project with Vitest

Scaffolds an E2E project for the plugin `my-plugin` using Vitest.

```bash
nx g @nx/plugin:e2e-project --pluginName my-plugin --npmPackageName my-plugin --pluginOutputPath dist/my-plugin --testRunner vitest
```
8 changes: 8 additions & 0 deletions packages/plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
"devDependencies": {
"nx": "workspace:*"
},
"peerDependencies": {
"@nx/vitest": "workspace:*"
},
"peerDependenciesMeta": {
"@nx/vitest": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
Expand Down
44 changes: 41 additions & 3 deletions packages/plugin/src/generators/e2e-project/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import 'nx/src/internal-testing-utils/mock-project-graph';
import {
Tree,
addProjectConfiguration,
readProjectConfiguration,
readJson,
getProjects,
writeJson,
readJson,
readProjectConfiguration,
updateJson,
writeJson,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { e2eProjectGenerator } from './e2e';
Expand Down Expand Up @@ -211,6 +211,44 @@ describe('NxPlugin e2e-project Generator', () => {
expect(tree.exists('my-plugin-e2e/.spec.swcrc')).toBeFalsy();
});

it('should add vitest support', async () => {
await e2eProjectGenerator(tree, {
pluginName: 'my-plugin',
pluginOutputPath: `dist/libs/my-plugin`,
npmPackageName: '@proj/my-plugin',
testRunner: 'vitest',
addPlugin: false,
});

const project = readProjectConfiguration(tree, 'my-plugin-e2e');

expect(project.targets.e2e.executor).toBe('@nx/vitest:test');
expect(project.targets.e2e).toMatchObject({
dependsOn: ['^build'],
options: expect.objectContaining({
pool: 'forks',
poolOptions: {
forks: {
singleFork: true,
},
},
}),
});

expect(tree.exists('my-plugin-e2e/tsconfig.spec.json')).toBeTruthy();
const vitestConfigExists =
tree.exists('my-plugin-e2e/vitest.config.ts') ||
tree.exists('my-plugin-e2e/vitest.config.mts');
expect(vitestConfigExists).toBeTruthy();

const vitestConfigPath = tree.exists('my-plugin-e2e/vitest.config.ts')
? 'my-plugin-e2e/vitest.config.ts'
: 'my-plugin-e2e/vitest.config.mts';
const vitestConfig = tree.read(vitestConfigPath, 'utf-8');
expect(vitestConfig).toContain('globalSetup');
expect(vitestConfig).toContain('globalTeardown');
Comment on lines +248 to +249
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets add an e2e test to e2e/plugin that tests that these work for vitest backed plugins, I'm a bit hesitant without it. @copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added e2e test in e2e/plugin/src/nx-plugin.test.ts that verifies plugins can be generated with vitest as the e2e test runner and that the tests execute successfully. Commit 6add8ac.

});

it('should setup the eslint builder', async () => {
await e2eProjectGenerator(tree, {
pluginName: 'my-plugin',
Expand Down
145 changes: 142 additions & 3 deletions packages/plugin/src/generators/e2e-project/e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
addProjectConfiguration,
ensurePackage,
formatFiles,
generateFiles,
getPackageManagerCommand,
Expand All @@ -10,12 +11,12 @@ import {
readNxJson,
readProjectConfiguration,
runTasksInSerial,
Tree,
updateJson,
updateProjectConfiguration,
writeJson,
type GeneratorCallback,
type ProjectConfiguration,
type Tree,
} from '@nx/devkit';
import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils';
import { LinterType, lintProjectGenerator } from '@nx/eslint';
Expand All @@ -32,10 +33,13 @@ import {
addProjectToTsSolutionWorkspace,
isUsingTsSolutionSetup,
} from '@nx/js/src/utils/typescript/ts-solution-setup';
import type { VitestGeneratorSchema } from '@nx/vitest/generators';
import type { PackageJson } from 'nx/src/utils/package-json';
import { join } from 'path';
import type { Schema } from './schema';

const nxVersion = require('../../../package.json').version;

interface NormalizedSchema extends Schema {
projectRoot: string;
projectName: string;
Expand Down Expand Up @@ -197,6 +201,130 @@ async function addJest(host: Tree, options: NormalizedSchema) {
return jestTask;
}

async function addVitest(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);
}

// Ensure @nx/vitest is installed before using it
ensurePackage('@nx/vitest', nxVersion);
const { configurationGenerator: vitestConfigurationGenerator } = await import(
'@nx/vitest/generators'
);

const vitestTask = await vitestConfigurationGenerator(host, {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot This call should use ensurePackage, as vitest may not be installed yet. We can't directly import this, though we can import it's types so we can reference them in the dynamic import.

coverageProvider should likely be null, since its e2e we wouldn't really collect coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to use ensurePackage for dynamic import of @nx/vitest and removed explicit coverageProvider since it will use the default. Changes in commit bcce4fe.

project: options.projectName,
testTarget: 'e2e',
skipFormat: true,
addPlugin: options.addPlugin,
testEnvironment: 'node',
coverageProvider: 'none',
} satisfies Partial<VitestGeneratorSchema>);

const { startLocalRegistryPath, stopLocalRegistryPath } =
addLocalRegistryScripts(host);

// Add globalSetup and globalTeardown to vitest config
// Check for both .mts and .ts extensions (mts is checked first as it's the default created by @nx/vitest)
const vitestConfigExtensions = ['mts', 'ts'];
let vitestConfigPath: string | undefined;

for (const ext of vitestConfigExtensions) {
const configPath = joinPathFragments(
options.projectRoot,
`vitest.config.${ext}`
);
if (host.exists(configPath)) {
vitestConfigPath = configPath;
break;
}
}

if (vitestConfigPath) {
let vitestConfig = host.read(vitestConfigPath, 'utf-8');
const globalSetupPath = join(
offsetFromRoot(options.projectRoot),
startLocalRegistryPath
);
const globalTeardownPath = join(
offsetFromRoot(options.projectRoot),
stopLocalRegistryPath
);

// Insert globalSetup and globalTeardown in the test config
// Look for 'test: {' and insert our properties right after the opening brace
const testConfigRegex = /(test:\s*\{\s*)/;
const match = testConfigRegex.exec(vitestConfig);

if (match) {
// Extract the indentation from the next line to maintain consistent formatting
const afterMatch = vitestConfig.slice(match.index + match[0].length);
const nextLineMatch = afterMatch.match(/\n(\s*)/);
const indent = nextLineMatch ? nextLineMatch[1] : ' ';

vitestConfig = vitestConfig.replace(
testConfigRegex,
`$1\n${indent}globalSetup: '${globalSetupPath}',\n${indent}globalTeardown: '${globalTeardownPath}',`
);
host.write(vitestConfigPath, vitestConfig);
} else {
// If we can't find the test config block, log a warning
throw new Error(
`Could not find test configuration block in ${vitestConfigPath}. Please manually add globalSetup and globalTeardown properties.`
);
}
} else {
// This should not happen as the vitest configuration generator should create the config file
throw new Error(
`Could not find Vitest config for project ${options.projectName} at ${options.projectRoot}`
);
}

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,
pool: 'forks',
poolOptions: {
forks: {
singleFork: true,
},
},
},
};

updateProjectConfiguration(host, options.projectName, project);
}

return vitestTask;
}

async function addLintingToApplication(
tree: Tree,
options: NormalizedSchema
Expand All @@ -207,7 +335,7 @@ async function addLintingToApplication(
tsConfigPaths: [
joinPathFragments(options.projectRoot, 'tsconfig.app.json'),
],
unitTestRunner: 'jest',
unitTestRunner: options.testRunner ?? 'jest',
skipFormat: true,
setParserOptionsProject: false,
addPlugin: options.addPlugin,
Expand Down Expand Up @@ -238,13 +366,24 @@ export async function e2eProjectGeneratorInternal(host: Tree, schema: Schema) {

validatePlugin(host, schema.pluginName);
const options = await normalizeOptions(host, schema);

// Default to jest if no testRunner is specified
options.testRunner = options.testRunner ?? 'jest';

addFiles(host, options);
tasks.push(
await setupVerdaccio(host, {
skipFormat: true,
})
);
tasks.push(await addJest(host, options));

// Add test runner based on the testRunner option
if (options.testRunner === 'vitest') {
tasks.push(await addVitest(host, options));
} else {
tasks.push(await addJest(host, options));
}

updatePluginPackageJson(host, options);

if (options.linter !== 'none') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ describe('<%= pluginName %>', () => {
beforeAll(() => {
projectDirectory = createTestProject();

// The plugin has been built and published to a local registry in the jest globalSetup
// The plugin has been built and published to a local registry in the 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,
});
});
}, 30_000);

afterAll(() => {
if (projectDirectory) {
Expand Down
1 change: 1 addition & 0 deletions packages/plugin/src/generators/e2e-project/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface Schema {
projectDirectory?: string;
pluginOutputPath?: string;
jestConfig?: string;
testRunner?: 'jest' | 'vitest';
linter?: Linter | LinterType;
skipFormat?: boolean;
rootProject?: boolean;
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin/src/generators/e2e-project/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
"type": "string",
"description": "Jest config file."
},
"testRunner": {
"type": "string",
"enum": ["jest", "vitest"],
"description": "Test runner to use for the e2e tests.",
"default": "jest"
},
"linter": {
"description": "The tool to use for running lint checks.",
"type": "string",
Expand Down
16 changes: 16 additions & 0 deletions packages/plugin/src/generators/plugin/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,22 @@ describe('NxPlugin Plugin Generator', () => {
const projects = getProjects(tree);
expect(projects.has('my-plugin-e2e')).toBe(false);
});

it('should generate e2e project with jest', async () => {
await pluginGenerator(tree, getSchema({ e2eTestRunner: 'jest' }));
const projects = getProjects(tree);
expect(projects.has('my-plugin-e2e')).toBe(true);
const e2eProject = projects.get('my-plugin-e2e');
expect(e2eProject.targets.e2e.executor).toBe('@nx/jest:jest');
});

it('should generate e2e project with vitest', async () => {
await pluginGenerator(tree, getSchema({ e2eTestRunner: 'vitest' }));
const projects = getProjects(tree);
expect(projects.has('my-plugin-e2e')).toBe(true);
const e2eProject = projects.get('my-plugin-e2e');
expect(e2eProject.targets.e2e.executor).toBe('@nx/vitest:test');
});
});

describe('TS solution setup', () => {
Expand Down
Loading
Loading