## Description This PR fixes an issue where asset files copied during a build using the `@nx/js:tsc` executor are placed in the wrong directory depending on the current working directory from which the `nx` command is executed. This behavior becomes particularly problematic in scenarios like release workflows that rely on `preVersionCommand` to run E2E tests. For instance, when using tools like Jest from the root of an E2E project, scripts like `start-local-registry` may trigger a build and run the `preVersionCommand`. However, instead of placing assets in the expected `dist` folder of the project, they are incorrectly copied relative to the E2E folder’s location. ## Reproduction Steps 1. Create a new Nx workspace: ```bash npx --yes create-nx-workspace assets-issue --preset=ts --no-interactive cd assets-issue ``` 2. Add the Nx Plugin package: ```bash nx add @nx/plugin ``` 3. Generate a new plugin: ```bash nx g @nx/plugin:plugin packages/my-plugin --linter eslint --unitTestRunner jest ``` 4. Add a generator to the plugin: ```bash nx g @nx/plugin:generator packages/my-plugin/src/generators/my-generator ``` 5. Build the plugin from the workspace root: ```bash nx build my-plugin ``` ✅ Assets are copied correctly: ``` dist/packages/my-plugin/generators/files/src/index.ts.template dist/packages/my-plugin/generators/schema.json dist/packages/my-plugin/generators/schema.d.ts ``` 6. Now build the same project from a nested folder: ```bash mkdir e2e && cd e2e nx build my-plugin --skip-nx-cache ``` ❌ Assets are copied relative to the current folder: ``` e2e/packages/my-plugin/dist/generators/files/src/index.ts.template e2e/packages/my-plugin/dist/generators/schema.json e2e/packages/my-plugin/dist/generators/schema.d.ts ``` ## Expected Behavior The build output—especially copied assets—should always respect the project’s `outputPath` configuration regardless of where the `nx` command is invoked from. The behavior should be consistent and **not influenced by `process.cwd()`**.
291 lines
8.5 KiB
TypeScript
291 lines
8.5 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
import { CopyAssetsHandler } from './copy-assets-handler';
|
|
|
|
import { Subject } from 'rxjs';
|
|
import type { ChangedFile } from 'nx/src/daemon/client/client';
|
|
|
|
const mockWatcher = new Subject<ChangedFile>();
|
|
|
|
jest.mock(
|
|
'nx/src/daemon/client/client',
|
|
(): Partial<typeof import('nx/src/daemon/client/client')> => {
|
|
const original = jest.requireActual('nx/src/daemon/client/client');
|
|
return {
|
|
...original,
|
|
daemonClient: {
|
|
registerFileWatcher: async (
|
|
config: unknown,
|
|
callback: (
|
|
err,
|
|
data: {
|
|
changedProjects: string[];
|
|
changedFiles: ChangedFile[];
|
|
}
|
|
) => void
|
|
) => {
|
|
mockWatcher.subscribe((data) => {
|
|
callback(null, {
|
|
changedProjects: [],
|
|
changedFiles: [data],
|
|
});
|
|
});
|
|
return () => {};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
);
|
|
|
|
function createMockedWatchedFile(path: string) {
|
|
mockWatcher.next({
|
|
type: 'create',
|
|
path,
|
|
});
|
|
}
|
|
|
|
function deletedMockedWatchedFile(path: string) {
|
|
mockWatcher.next({
|
|
type: 'delete',
|
|
path,
|
|
});
|
|
}
|
|
|
|
function updateMockedWatchedFile(path: string) {
|
|
mockWatcher.next({
|
|
type: 'update',
|
|
path,
|
|
});
|
|
}
|
|
|
|
describe('AssetInputOutputHandler', () => {
|
|
let sut: CopyAssetsHandler;
|
|
let rootDir: string;
|
|
let projectDir: string;
|
|
let outputDir: string;
|
|
let callback: jest.SpyInstance;
|
|
let originalCwd: string;
|
|
|
|
beforeEach(() => {
|
|
// Store original cwd to restore later
|
|
originalCwd = process.cwd();
|
|
|
|
// Resolve to real paths to avoid symlink discrepancies with watcher.
|
|
const tmp = fs.realpathSync(path.join(os.tmpdir()));
|
|
|
|
callback = jest.fn();
|
|
rootDir = path.join(tmp, 'nx-assets-test');
|
|
projectDir = path.join(rootDir, 'mylib');
|
|
outputDir = path.join(rootDir, 'dist/mylib');
|
|
|
|
// Reset temp directory
|
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
fs.mkdirSync(path.join(projectDir, 'docs/a/b'), { recursive: true });
|
|
|
|
// Workspace ignore files
|
|
fs.writeFileSync(path.join(rootDir, '.gitignore'), `git-ignore.md`);
|
|
fs.writeFileSync(path.join(rootDir, '.nxignore'), `nx-ignore.md`);
|
|
|
|
sut = new CopyAssetsHandler({
|
|
rootDir,
|
|
projectDir,
|
|
outputDir,
|
|
callback: callback as any,
|
|
assets: [
|
|
'mylib/*.md',
|
|
{
|
|
input: 'mylib/docs',
|
|
glob: '**/*.md',
|
|
output: 'docs',
|
|
ignore: ['ignore.md', '**/nested-ignore.md'],
|
|
},
|
|
'LICENSE',
|
|
],
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore original cwd
|
|
process.chdir(originalCwd);
|
|
});
|
|
|
|
test('watchAndProcessOnAssetChange', async () => {
|
|
const dispose = await sut.watchAndProcessOnAssetChange();
|
|
|
|
createMockedWatchedFile(path.join(rootDir, 'LICENSE'));
|
|
createMockedWatchedFile(path.join(projectDir, 'README.md'));
|
|
createMockedWatchedFile(path.join(projectDir, 'docs/test1.md'));
|
|
createMockedWatchedFile(path.join(projectDir, 'docs/test2.md'));
|
|
createMockedWatchedFile(path.join(projectDir, 'docs/ignore.md'));
|
|
createMockedWatchedFile(path.join(projectDir, 'docs/git-ignore.md'));
|
|
createMockedWatchedFile(path.join(projectDir, 'docs/nx-ignore.md'));
|
|
createMockedWatchedFile(path.join(projectDir, 'docs/a/b/nested-ignore.md'));
|
|
updateMockedWatchedFile(path.join(projectDir, 'docs/test1.md'));
|
|
deletedMockedWatchedFile(path.join(projectDir, 'docs/test1.md'));
|
|
deletedMockedWatchedFile(path.join(projectDir, 'docs/test2.md'));
|
|
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'LICENSE'),
|
|
dest: path.join(rootDir, 'dist/mylib/LICENSE'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/README.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/README.md'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/docs/test1.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test1.md'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/docs/test2.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test2.md'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'update',
|
|
src: path.join(rootDir, 'mylib/docs/test1.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test1.md'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'delete',
|
|
src: path.join(rootDir, 'mylib/docs/test1.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test1.md'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'delete',
|
|
src: path.join(rootDir, 'mylib/docs/test2.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test2.md'),
|
|
},
|
|
]);
|
|
expect(callback).not.toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/docs/a/b/nested-ignore.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/a/b/nested-ignore.md'),
|
|
},
|
|
]);
|
|
|
|
dispose();
|
|
});
|
|
|
|
test('processAllAssetsOnce', async () => {
|
|
fs.writeFileSync(path.join(rootDir, 'LICENSE'), 'license');
|
|
fs.writeFileSync(path.join(projectDir, 'README.md'), 'readme');
|
|
fs.writeFileSync(path.join(projectDir, 'docs/test1.md'), 'test');
|
|
fs.writeFileSync(path.join(projectDir, 'docs/test2.md'), 'test');
|
|
fs.writeFileSync(path.join(projectDir, 'docs/ignore.md'), 'IGNORE ME');
|
|
fs.writeFileSync(path.join(projectDir, 'docs/git-ignore.md'), 'IGNORE ME');
|
|
fs.writeFileSync(path.join(projectDir, 'docs/nx-ignore.md'), 'IGNORE ME');
|
|
fs.writeFileSync(
|
|
path.join(projectDir, 'docs/a/b/nested-ignore.md'),
|
|
'IGNORE ME'
|
|
);
|
|
|
|
await sut.processAllAssetsOnce();
|
|
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'LICENSE'),
|
|
dest: path.join(rootDir, 'dist/mylib/LICENSE'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/README.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/README.md'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/docs/test1.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test1.md'),
|
|
},
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/docs/test2.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test2.md'),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('should copy assets to correct location when running from nested directory', async () => {
|
|
// Create a nested directory structure to simulate running from a subdirectory
|
|
const nestedDir = path.join(rootDir, 'e2e', 'integration-tests');
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
|
|
// Change to nested directory to simulate running nx command from there
|
|
process.chdir(nestedDir);
|
|
|
|
// Create test files
|
|
fs.writeFileSync(path.join(rootDir, 'LICENSE'), 'license');
|
|
fs.writeFileSync(path.join(projectDir, 'README.md'), 'readme');
|
|
fs.writeFileSync(path.join(projectDir, 'docs/test1.md'), 'test');
|
|
|
|
// Create CopyAssetsHandler with relative outputDir (this is where the bug manifests)
|
|
const nestedSut = new CopyAssetsHandler({
|
|
rootDir,
|
|
projectDir,
|
|
outputDir: 'dist/mylib', // relative path - this triggers the bug
|
|
callback: callback as any,
|
|
assets: [
|
|
'mylib/*.md',
|
|
{
|
|
input: 'mylib/docs',
|
|
glob: '**/*.md',
|
|
output: 'docs',
|
|
},
|
|
'LICENSE',
|
|
],
|
|
});
|
|
|
|
await nestedSut.processAllAssetsOnce();
|
|
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'LICENSE'),
|
|
dest: path.join(rootDir, 'dist/mylib/LICENSE'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/README.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/README.md'),
|
|
},
|
|
]);
|
|
expect(callback).toHaveBeenCalledWith([
|
|
{
|
|
type: 'create',
|
|
src: path.join(rootDir, 'mylib/docs/test1.md'),
|
|
dest: path.join(rootDir, 'dist/mylib/docs/test1.md'),
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
function wait(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|