nx/packages/js/src/utils/assets/copy-assets-handler.spec.ts
Jonathan Gelin fd31fa633d
fix(js): resolve asset paths relative to workspace root instead of cwd (#31664)
## 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()`**.
2025-06-23 08:42:59 +02:00

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));
}