Skip to content

Commit 09a1959

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 09a1959

3 files changed

Lines changed: 157 additions & 1 deletion

File tree

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

Lines changed: 75 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 * as fs from 'node:fs/promises';
10+
import * as os from 'node:os';
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,77 @@ 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'] ?? os.tmpdir();
421+
externalDir = await fs.mkdtemp(path.join(baseTmpDir, 'angular-cli-asset-test-'));
422+
await fs.mkdir(path.join(externalDir, 'nested'), { recursive: true });
423+
await fs.writeFile(path.join(externalDir, 'shared-root.txt'), 'shared root asset');
424+
await fs.writeFile(
425+
path.join(externalDir, 'nested', 'shared-nested.txt'),
426+
'shared nested asset',
427+
);
428+
});
429+
430+
afterEach(async () => {
431+
await fs.rm(externalDir, { recursive: true, force: true });
432+
});
433+
434+
it('copies assets from a symlinked directory pointing outside the workspace root', async () => {
435+
const symlinkPath = harness.resolvePath('src/shared-assets');
436+
await fs.symlink(externalDir, symlinkPath, 'junction');
437+
438+
harness.useTarget('build', {
439+
...BASE_OPTIONS,
440+
assets: [{ glob: '**/*', input: 'src/shared-assets', output: 'assets' }],
441+
});
442+
443+
const { result } = await harness.executeOnce();
444+
445+
expect(result?.success).toBe(true);
446+
harness.expectFile('dist/browser/assets/shared-root.txt').content.toBe('shared root asset');
447+
harness
448+
.expectFile('dist/browser/assets/nested/shared-nested.txt')
449+
.content.toBe('shared nested asset');
450+
});
451+
452+
it('copies assets from a symlinked directory outside workspace root with followSymlinks: true', async () => {
453+
const symlinkPath = harness.resolvePath('src/shared-assets');
454+
await fs.symlink(externalDir, symlinkPath, 'junction');
455+
456+
harness.useTarget('build', {
457+
...BASE_OPTIONS,
458+
assets: [
459+
{ glob: '**/*', input: 'src/shared-assets', output: 'assets', followSymlinks: true },
460+
],
461+
});
462+
463+
const { result } = await harness.executeOnce();
464+
465+
expect(result?.success).toBe(true);
466+
harness.expectFile('dist/browser/assets/shared-root.txt').content.toBe('shared root asset');
467+
harness
468+
.expectFile('dist/browser/assets/nested/shared-nested.txt')
469+
.content.toBe('shared nested asset');
470+
});
471+
472+
it('copies a symlinked file pointing outside the workspace root', async () => {
473+
const symlinkPath = harness.resolvePath('src/external-file.txt');
474+
await fs.symlink(path.join(externalDir, 'shared-root.txt'), symlinkPath, 'file');
475+
476+
harness.useTarget('build', {
477+
...BASE_OPTIONS,
478+
assets: ['src/external-file.txt'],
479+
});
480+
481+
const { result } = await harness.executeOnce();
482+
483+
expect(result?.success).toBe(true);
484+
harness.expectFile('dist/browser/external-file.txt').content.toBe('shared root asset');
485+
});
486+
});
412487
});
413488
});

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

Lines changed: 57 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 * as fs from 'node:fs/promises';
10+
import * as os from 'node:os';
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,59 @@ 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'] ?? os.tmpdir();
410+
externalDir = await fs.mkdtemp(path.join(baseTmpDir, 'angular-cli-asset-test-'));
411+
await fs.mkdir(path.join(externalDir, 'nested'), { recursive: true });
412+
await fs.writeFile(path.join(externalDir, 'shared-root.txt'), 'shared root asset');
413+
await fs.writeFile(
414+
path.join(externalDir, 'nested', 'shared-nested.txt'),
415+
'shared nested asset',
416+
);
417+
});
418+
419+
afterEach(async () => {
420+
await fs.rm(externalDir, { recursive: true, force: true });
421+
});
422+
423+
it('copies assets from a symlinked directory outside workspace root with followSymlinks: true', async () => {
424+
const symlinkPath = harness.resolvePath('src/shared-assets');
425+
await fs.symlink(externalDir, symlinkPath, 'junction');
426+
427+
harness.useTarget('build', {
428+
...BASE_OPTIONS,
429+
assets: [
430+
{ glob: '**/*', input: 'src/shared-assets', output: 'assets', followSymlinks: true },
431+
],
432+
});
433+
434+
const { result } = await harness.executeOnce();
435+
436+
expect(result?.success).toBe(true);
437+
harness.expectFile('dist/assets/shared-root.txt').content.toBe('shared root asset');
438+
harness
439+
.expectFile('dist/assets/nested/shared-nested.txt')
440+
.content.toBe('shared nested asset');
441+
});
442+
443+
it('copies a symlinked file pointing outside the workspace root', async () => {
444+
const symlinkPath = harness.resolvePath('src/external-file.txt');
445+
await fs.symlink(path.join(externalDir, 'shared-root.txt'), symlinkPath, 'file');
446+
447+
harness.useTarget('build', {
448+
...BASE_OPTIONS,
449+
assets: ['src/external-file.txt'],
450+
});
451+
452+
const { result } = await harness.executeOnce();
453+
454+
expect(result?.success).toBe(true);
455+
harness.expectFile('dist/external-file.txt').content.toBe('shared root asset');
456+
});
457+
});
401458
});
402459
});

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

Lines changed: 25 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,27 @@ 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 rm(outsideAssetsDir, { force: true, recursive: true });
90+
}
6791
}

0 commit comments

Comments
 (0)