Skip to content

Commit bf12265

Browse files
committed
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.
1 parent 91b0947 commit bf12265

3 files changed

Lines changed: 162 additions & 1 deletion

File tree

‎packages/angular/build/src/builders/application/tests/options/assets_spec.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import assert from 'node:assert';
10+
import * as fs from 'node:fs/promises';
11+
import * as path from 'node:path';
912
import { buildApplication } from '../../index';
1013
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
1114

@@ -409,5 +412,78 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
409412
harness.expectFile('dist/browser/test.svg').toNotExist();
410413
});
411414
});
415+
416+
describe('symlinks and monorepo external assets', () => {
417+
let externalDir: string;
418+
419+
beforeEach(async () => {
420+
const baseTmpDir = process.env['TEST_TMPDIR'];
421+
assert(baseTmpDir, 'TEST_TMPDIR must be set');
422+
externalDir = await fs.mkdtemp(path.join(baseTmpDir, 'angular-cli-asset-test-'));
423+
await fs.mkdir(path.join(externalDir, 'nested'), { recursive: true });
424+
await fs.writeFile(path.join(externalDir, 'shared-root.txt'), 'shared root asset');
425+
await fs.writeFile(
426+
path.join(externalDir, 'nested', 'shared-nested.txt'),
427+
'shared nested asset',
428+
);
429+
});
430+
431+
afterEach(async () => {
432+
await fs.rm(externalDir, { recursive: true, force: true });
433+
});
434+
435+
it('copies assets from a symlinked directory pointing outside the workspace root', async () => {
436+
const symlinkPath = harness.resolvePath('src/shared-assets');
437+
await fs.symlink(externalDir, symlinkPath, 'junction');
438+
439+
harness.useTarget('build', {
440+
...BASE_OPTIONS,
441+
assets: [{ glob: '**/*', input: 'src/shared-assets', output: 'assets' }],
442+
});
443+
444+
const { result } = await harness.executeOnce();
445+
446+
expect(result?.success).toBe(true);
447+
harness.expectFile('dist/browser/assets/shared-root.txt').content.toBe('shared root asset');
448+
harness
449+
.expectFile('dist/browser/assets/nested/shared-nested.txt')
450+
.content.toBe('shared nested asset');
451+
});
452+
453+
it('copies assets from a symlinked directory outside workspace root with followSymlinks: true', async () => {
454+
const symlinkPath = harness.resolvePath('src/shared-assets');
455+
await fs.symlink(externalDir, symlinkPath, 'junction');
456+
457+
harness.useTarget('build', {
458+
...BASE_OPTIONS,
459+
assets: [
460+
{ glob: '**/*', input: 'src/shared-assets', output: 'assets', followSymlinks: true },
461+
],
462+
});
463+
464+
const { result } = await harness.executeOnce();
465+
466+
expect(result?.success).toBe(true);
467+
harness.expectFile('dist/browser/assets/shared-root.txt').content.toBe('shared root asset');
468+
harness
469+
.expectFile('dist/browser/assets/nested/shared-nested.txt')
470+
.content.toBe('shared nested asset');
471+
});
472+
473+
it('copies a symlinked file pointing outside the workspace root', async () => {
474+
const symlinkPath = harness.resolvePath('src/external-file.txt');
475+
await fs.symlink(path.join(externalDir, 'shared-root.txt'), symlinkPath, 'file');
476+
477+
harness.useTarget('build', {
478+
...BASE_OPTIONS,
479+
assets: ['src/external-file.txt'],
480+
});
481+
482+
const { result } = await harness.executeOnce();
483+
484+
expect(result?.success).toBe(true);
485+
harness.expectFile('dist/browser/external-file.txt').content.toBe('shared root asset');
486+
});
487+
});
412488
});
413489
});

‎packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import assert from 'node:assert';
10+
import * as fs from 'node:fs/promises';
11+
import * as path from 'node:path';
912
import { buildWebpackBrowser } from '../../index';
1013
import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup';
1114

@@ -398,5 +401,60 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
398401
harness.expectFile('dist/test.svg').toNotExist();
399402
});
400403
});
404+
405+
describe('symlinks and monorepo external assets', () => {
406+
let externalDir: string;
407+
408+
beforeEach(async () => {
409+
const baseTmpDir = process.env['TEST_TMPDIR'];
410+
assert(baseTmpDir, 'TEST_TMPDIR must be set');
411+
externalDir = await fs.mkdtemp(path.join(baseTmpDir, 'angular-cli-asset-test-'));
412+
await fs.mkdir(path.join(externalDir, 'nested'), { recursive: true });
413+
await fs.writeFile(path.join(externalDir, 'shared-root.txt'), 'shared root asset');
414+
await fs.writeFile(
415+
path.join(externalDir, 'nested', 'shared-nested.txt'),
416+
'shared nested asset',
417+
);
418+
});
419+
420+
afterEach(async () => {
421+
await fs.rm(externalDir, { recursive: true, force: true });
422+
});
423+
424+
it('copies assets from a symlinked directory outside workspace root with followSymlinks: true', async () => {
425+
const symlinkPath = harness.resolvePath('src/shared-assets');
426+
await fs.symlink(externalDir, symlinkPath, 'junction');
427+
428+
harness.useTarget('build', {
429+
...BASE_OPTIONS,
430+
assets: [
431+
{ glob: '**/*', input: 'src/shared-assets', output: 'assets', followSymlinks: true },
432+
],
433+
});
434+
435+
const { result } = await harness.executeOnce();
436+
437+
expect(result?.success).toBe(true);
438+
harness.expectFile('dist/assets/shared-root.txt').content.toBe('shared root asset');
439+
harness
440+
.expectFile('dist/assets/nested/shared-nested.txt')
441+
.content.toBe('shared nested asset');
442+
});
443+
444+
it('copies a symlinked file pointing outside the workspace root', async () => {
445+
const symlinkPath = harness.resolvePath('src/external-file.txt');
446+
await fs.symlink(path.join(externalDir, 'shared-root.txt'), symlinkPath, 'file');
447+
448+
harness.useTarget('build', {
449+
...BASE_OPTIONS,
450+
assets: ['src/external-file.txt'],
451+
});
452+
453+
const { result } = await harness.executeOnce();
454+
455+
expect(result?.success).toBe(true);
456+
harness.expectFile('dist/external-file.txt').content.toBe('shared root asset');
457+
});
458+
});
401459
});
402460
});

‎tests/e2e/tests/build/assets.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from 'node:assert/strict';
2-
import { writeFile, stat, mkdir, symlink, utimes } from 'node:fs/promises';
2+
import { writeFile, stat, mkdir, symlink, utimes, rm } from 'node:fs/promises';
3+
import { resolve } from 'node:path';
34
import { expectFileToExist, expectFileToMatch } from '../../utils/fs';
45
import { ng } from '../../utils/process';
56
import { updateJsonFile } from '../../utils/project';
@@ -64,4 +65,30 @@ export default async function () {
6465
await expectFileToExist('dist/test-project/browser/symlinkDir/subdir1/b.txt');
6566
await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/c.txt');
6667
await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/subsubdir1/d.txt');
68+
69+
// Ensure symlinks pointing outside workspace root (monorepo setups) are followed
70+
const outsideAssetsDir = resolve(process.cwd(), '../outside-monorepo-assets');
71+
await mkdir(`${outsideAssetsDir}/nested`, { recursive: true });
72+
try {
73+
await symlink(outsideAssetsDir, 'public/outsideAssets');
74+
await Promise.all([
75+
writeFile(`${outsideAssetsDir}/outside.txt`, 'outside asset'),
76+
writeFile(`${outsideAssetsDir}/nested/nested.txt`, 'nested outside asset'),
77+
]);
78+
79+
await ng('build', '--configuration=development');
80+
81+
await expectFileToExist('dist/test-project/browser/outsideAssets/outside.txt');
82+
await expectFileToExist('dist/test-project/browser/outsideAssets/nested/nested.txt');
83+
await expectFileToMatch('dist/test-project/browser/outsideAssets/outside.txt', 'outside asset');
84+
await expectFileToMatch(
85+
'dist/test-project/browser/outsideAssets/nested/nested.txt',
86+
'nested outside asset',
87+
);
88+
} finally {
89+
await Promise.all([
90+
rm(outsideAssetsDir, { force: true, recursive: true }),
91+
rm('public/outsideAssets', { force: true }),
92+
]);
93+
}
6794
}

0 commit comments

Comments
 (0)