From bf1226551e1d1bd59563ec1cf62765fa9112c6d4 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:41:57 -0400 Subject: [PATCH] test: add unit and e2e tests for monorepo symlinked assets Ensure asset resolution properly handles symbolic links targeting directories and files located outside the workspace root, as commonly found in monorepo structures with shared packages or hoisted dependencies. Both application and browser builders are covered to prevent regressions when traversing external symlinks, and the end-to-end assets test verifies nested directory copying for out-of-tree targets. --- .../application/tests/options/assets_spec.ts | 76 +++++++++++++++++++ .../browser/tests/options/assets_spec.ts | 58 ++++++++++++++ tests/e2e/tests/build/assets.ts | 29 ++++++- 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/packages/angular/build/src/builders/application/tests/options/assets_spec.ts b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts index afa42cc1804e..2a62782bce7e 100644 --- a/packages/angular/build/src/builders/application/tests/options/assets_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts @@ -6,6 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ +import assert from 'node:assert'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { buildApplication } from '../../index'; import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; @@ -409,5 +412,78 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/test.svg').toNotExist(); }); }); + + describe('symlinks and monorepo external assets', () => { + let externalDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR must be set'); + externalDir = await fs.mkdtemp(path.join(baseTmpDir, 'angular-cli-asset-test-')); + await fs.mkdir(path.join(externalDir, 'nested'), { recursive: true }); + await fs.writeFile(path.join(externalDir, 'shared-root.txt'), 'shared root asset'); + await fs.writeFile( + path.join(externalDir, 'nested', 'shared-nested.txt'), + 'shared nested asset', + ); + }); + + afterEach(async () => { + await fs.rm(externalDir, { recursive: true, force: true }); + }); + + it('copies assets from a symlinked directory pointing outside the workspace root', async () => { + const symlinkPath = harness.resolvePath('src/shared-assets'); + await fs.symlink(externalDir, symlinkPath, 'junction'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src/shared-assets', output: 'assets' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/assets/shared-root.txt').content.toBe('shared root asset'); + harness + .expectFile('dist/browser/assets/nested/shared-nested.txt') + .content.toBe('shared nested asset'); + }); + + it('copies assets from a symlinked directory outside workspace root with followSymlinks: true', async () => { + const symlinkPath = harness.resolvePath('src/shared-assets'); + await fs.symlink(externalDir, symlinkPath, 'junction'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { glob: '**/*', input: 'src/shared-assets', output: 'assets', followSymlinks: true }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/assets/shared-root.txt').content.toBe('shared root asset'); + harness + .expectFile('dist/browser/assets/nested/shared-nested.txt') + .content.toBe('shared nested asset'); + }); + + it('copies a symlinked file pointing outside the workspace root', async () => { + const symlinkPath = harness.resolvePath('src/external-file.txt'); + await fs.symlink(path.join(externalDir, 'shared-root.txt'), symlinkPath, 'file'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['src/external-file.txt'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/external-file.txt').content.toBe('shared root asset'); + }); + }); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts index c7ac266cf036..3f1002f6570d 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts @@ -6,6 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ +import assert from 'node:assert'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { buildWebpackBrowser } from '../../index'; import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; @@ -398,5 +401,60 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { harness.expectFile('dist/test.svg').toNotExist(); }); }); + + describe('symlinks and monorepo external assets', () => { + let externalDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR must be set'); + externalDir = await fs.mkdtemp(path.join(baseTmpDir, 'angular-cli-asset-test-')); + await fs.mkdir(path.join(externalDir, 'nested'), { recursive: true }); + await fs.writeFile(path.join(externalDir, 'shared-root.txt'), 'shared root asset'); + await fs.writeFile( + path.join(externalDir, 'nested', 'shared-nested.txt'), + 'shared nested asset', + ); + }); + + afterEach(async () => { + await fs.rm(externalDir, { recursive: true, force: true }); + }); + + it('copies assets from a symlinked directory outside workspace root with followSymlinks: true', async () => { + const symlinkPath = harness.resolvePath('src/shared-assets'); + await fs.symlink(externalDir, symlinkPath, 'junction'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { glob: '**/*', input: 'src/shared-assets', output: 'assets', followSymlinks: true }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/assets/shared-root.txt').content.toBe('shared root asset'); + harness + .expectFile('dist/assets/nested/shared-nested.txt') + .content.toBe('shared nested asset'); + }); + + it('copies a symlinked file pointing outside the workspace root', async () => { + const symlinkPath = harness.resolvePath('src/external-file.txt'); + await fs.symlink(path.join(externalDir, 'shared-root.txt'), symlinkPath, 'file'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['src/external-file.txt'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/external-file.txt').content.toBe('shared root asset'); + }); + }); }); }); diff --git a/tests/e2e/tests/build/assets.ts b/tests/e2e/tests/build/assets.ts index 0d3c718b270e..4a8fa7548d35 100644 --- a/tests/e2e/tests/build/assets.ts +++ b/tests/e2e/tests/build/assets.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { writeFile, stat, mkdir, symlink, utimes } from 'node:fs/promises'; +import { writeFile, stat, mkdir, symlink, utimes, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { expectFileToExist, expectFileToMatch } from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile } from '../../utils/project'; @@ -64,4 +65,30 @@ export default async function () { await expectFileToExist('dist/test-project/browser/symlinkDir/subdir1/b.txt'); await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/c.txt'); await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/subsubdir1/d.txt'); + + // Ensure symlinks pointing outside workspace root (monorepo setups) are followed + const outsideAssetsDir = resolve(process.cwd(), '../outside-monorepo-assets'); + await mkdir(`${outsideAssetsDir}/nested`, { recursive: true }); + try { + await symlink(outsideAssetsDir, 'public/outsideAssets'); + await Promise.all([ + writeFile(`${outsideAssetsDir}/outside.txt`, 'outside asset'), + writeFile(`${outsideAssetsDir}/nested/nested.txt`, 'nested outside asset'), + ]); + + await ng('build', '--configuration=development'); + + await expectFileToExist('dist/test-project/browser/outsideAssets/outside.txt'); + await expectFileToExist('dist/test-project/browser/outsideAssets/nested/nested.txt'); + await expectFileToMatch('dist/test-project/browser/outsideAssets/outside.txt', 'outside asset'); + await expectFileToMatch( + 'dist/test-project/browser/outsideAssets/nested/nested.txt', + 'nested outside asset', + ); + } finally { + await Promise.all([ + rm(outsideAssetsDir, { force: true, recursive: true }), + rm('public/outsideAssets', { force: true }), + ]); + } }