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()`**.
This commit is contained in:
Jonathan Gelin 2025-06-23 08:42:59 +02:00 committed by GitHub
parent 57e70d0e91
commit fd31fa633d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 71 additions and 2 deletions

View File

@ -66,8 +66,12 @@ describe('AssetInputOutputHandler', () => {
let projectDir: string; let projectDir: string;
let outputDir: string; let outputDir: string;
let callback: jest.SpyInstance; let callback: jest.SpyInstance;
let originalCwd: string;
beforeEach(() => { beforeEach(() => {
// Store original cwd to restore later
originalCwd = process.cwd();
// Resolve to real paths to avoid symlink discrepancies with watcher. // Resolve to real paths to avoid symlink discrepancies with watcher.
const tmp = fs.realpathSync(path.join(os.tmpdir())); const tmp = fs.realpathSync(path.join(os.tmpdir()));
@ -102,6 +106,11 @@ describe('AssetInputOutputHandler', () => {
}); });
}); });
afterEach(() => {
// Restore original cwd
process.chdir(originalCwd);
});
test('watchAndProcessOnAssetChange', async () => { test('watchAndProcessOnAssetChange', async () => {
const dispose = await sut.watchAndProcessOnAssetChange(); const dispose = await sut.watchAndProcessOnAssetChange();
@ -219,6 +228,61 @@ describe('AssetInputOutputHandler', () => {
}, },
]); ]);
}); });
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) { function wait(ms: number) {

View File

@ -89,16 +89,21 @@ export class CopyAssetsHandler {
let input: string; let input: string;
let output: string; let output: string;
let ignore: string[] | null = null; let ignore: string[] | null = null;
const resolvedOutputDir = path.isAbsolute(opts.outputDir)
? opts.outputDir
: path.resolve(opts.rootDir, opts.outputDir);
if (typeof f === 'string') { if (typeof f === 'string') {
pattern = f; pattern = f;
input = path.relative(opts.rootDir, opts.projectDir); input = path.relative(opts.rootDir, opts.projectDir);
output = path.relative(opts.rootDir, opts.outputDir); output = path.relative(opts.rootDir, resolvedOutputDir);
} else { } else {
isGlob = true; isGlob = true;
pattern = pathPosix.join(f.input, f.glob); pattern = pathPosix.join(f.input, f.glob);
input = f.input; input = f.input;
output = pathPosix.join( output = pathPosix.join(
path.relative(opts.rootDir, opts.outputDir), path.relative(opts.rootDir, resolvedOutputDir),
f.output f.output
); );
if (f.ignore) if (f.ignore)