From ccfabe3fccf24dcb734f34fe8ce5cc3a02ddffb8 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 1/2] 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 | 75 +++++++++++++++++++ .../browser/tests/options/assets_spec.ts | 58 ++++++++++++++ tests/e2e/tests/build/assets.ts | 29 ++++++- 3 files changed, 161 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 7ca19daefa53..90df1d2ef4ed 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,7 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ +import assert from 'node:assert'; import fs from 'node:fs/promises'; +import path from 'node:path'; import { buildApplication } from '../../index'; import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; @@ -448,5 +450,78 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/symlink-dir/test.txt').content.toBe('symlink-content'); }); }); + + 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 }), + ]); + } } From f56fd19c0d66efa664d54c6999adc4ebbc22424c Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:41:20 -0500 Subject: [PATCH 2/2] fixup! test: add unit and e2e tests for monorepo symlinked assets --- .../application/tests/options/assets_spec.ts | 17 ++++++++++++++++- .../browser/tests/options/assets_spec.ts | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) 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 90df1d2ef4ed..915910c80943 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 @@ -508,7 +508,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { .content.toBe('shared nested asset'); }); - it('copies a symlinked file pointing outside the workspace root', async () => { + it('does not copy a symlinked file pointing outside the workspace root by default', async () => { const symlinkPath = harness.resolvePath('src/external-file.txt'); await fs.symlink(path.join(externalDir, 'shared-root.txt'), symlinkPath, 'file'); @@ -519,6 +519,21 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/external-file.txt').toNotExist(); + }); + + it('copies a symlinked file pointing outside the workspace root with followSymlinks: true', 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: [{ glob: 'external-file.txt', input: 'src', output: '.', followSymlinks: true }], + }); + + 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 3f1002f6570d..1840cfd4c3ad 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 @@ -7,8 +7,8 @@ */ import assert from 'node:assert'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { buildWebpackBrowser } from '../../index'; import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup';