From 18d6d74478d75208c47d78ff520c50eb145d6785 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:19:02 +0000 Subject: [PATCH 1/4] fix(@angular/build): align library watch asset matching with initial build and deduplicate outputs - Respect `asset.followSymlinks` in watch mode by using `lstatSync` when `!asset.followSymlinks` and `statSync` when `asset.followSymlinks` is `true`. - Normalize `asset.glob` and `asset.ignore` patterns in `createAssetMatchers` to match `tinyglobby` semantics (stripping trailing slashes, expanding directory patterns with `/**`, normalizing POSIX paths, skipping empty strings, and passing `windows: false`). - Deduplicate collected and resolved assets by destination `path` so later asset configurations override earlier ones without concurrent `fs.cp` operations to the same output destination. - Queue nested emitted assets for deletion when a deleted parent directory is reported in watch mode. --- .../src/builders/library/pipeline/assets.ts | 96 ++++++++-- .../builders/library/pipeline/assets_spec.ts | 179 ++++++++++++++++++ .../library/tests/options/assets_spec.ts | 139 ++++++++++++++ 3 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 packages/angular/build/src/builders/library/pipeline/assets_spec.ts diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 90c6d5c10fa4..ce7930538e8a 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { statSync } from 'node:fs'; +import { lstatSync, statSync } from 'node:fs'; import path from 'node:path'; import picomatch from 'picomatch'; import type { NormalizedAssetsPattern } from '../../../utils'; @@ -38,6 +38,22 @@ export interface AssetMatcher { isMatch: picomatch.Matcher; } +/** + * Normalizes a glob pattern to match `tinyglobby` semantics by stripping trailing slashes, + * expanding directory patterns that do not end in `*` with `/**`, and normalizing POSIX segments. + * + * @param pattern The raw glob pattern string. + * @returns The normalized POSIX glob pattern. + */ +function normalizeGlobPattern(pattern: string): string { + let result = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; + if (!result.endsWith('*')) { + result += '/**'; + } + + return path.posix.normalize(result); +} + /** * Compiles picomatch matchers and POSIX input prefixes for each asset configuration. * @@ -46,9 +62,13 @@ export interface AssetMatcher { */ export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly AssetMatcher[] { return assets.map((asset) => { - const isMatch = picomatch(asset.glob, { + const ignore = [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])] + .filter((pattern) => pattern.length > 0) + .map(normalizeGlobPattern); + const isMatch = picomatch(asset.glob ? normalizeGlobPattern(asset.glob) : [], { dot: true, - ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], + windows: false, + ignore, }); const prefix = asset.input.endsWith('/') ? asset.input : `${asset.input}/`; @@ -79,21 +99,44 @@ export async function collectAssetsToEmit( if (modifiedFiles) { const matchers = assetMatchers ?? createAssetMatchers(assets); - const filesToEmit: DiskOutputFile[] = []; - const filesToDelete: string[] = []; + const filesToEmit = new Map(); + const filesToDelete = new Set(); for (const file of modifiedFiles) { - let isFile: boolean | undefined; + let isFileStat: boolean | undefined; + let isFileLstat: boolean | undefined; + + if (!emittedAssets.has(file) && emittedAssets.size > 0) { + const dirPrefix = file.endsWith('/') ? file : `${file}/`; + let checkedDir = false; + let isDirectory = false; + + for (const emittedFile of emittedAssets.keys()) { + if (!emittedFile.startsWith(dirPrefix)) { + continue; + } + + if (!checkedDir) { + checkedDir = true; + const stats = statSync(file, { throwIfNoEntry: false }); + isDirectory = stats?.isDirectory() ?? false; + isFileStat = stats?.isFile() ?? false; + if (isDirectory) { + break; + } + } + + filesToDelete.add(emittedFile); + } - if (emittedAssets.has(file)) { - isFile = statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; - if (!isFile) { - filesToDelete.push(file); + if (checkedDir && (isDirectory || !isFileStat)) { continue; } } - for (const { asset, prefix, isMatch } of matchers) { + let matched = false; + for (let matcherIndex = 0; matcherIndex < matchers.length; matcherIndex++) { + const { asset, prefix, isMatch } = matchers[matcherIndex]; if (!file.startsWith(prefix)) { continue; } @@ -103,24 +146,41 @@ export async function collectAssetsToEmit( continue; } - isFile ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + const isFile = asset.followSymlinks + ? (isFileStat ??= statSync(file, { throwIfNoEntry: false })?.isFile() ?? false) + : (isFileLstat ??= lstatSync(file, { throwIfNoEntry: false })?.isFile() ?? false); if (!isFile) { - break; + continue; } - filesToEmit.push(createDiskOutputFile(file, path.posix.join(asset.output, relative))); + matched = true; + const outputFile = createDiskOutputFile(file, path.posix.join(asset.output, relative)); + const existing = filesToEmit.get(outputFile.path); + if (!existing || matcherIndex >= existing.matcherIndex) { + filesToEmit.set(outputFile.path, { file: outputFile, matcherIndex }); + } + } + + if (emittedAssets.has(file) && !matched) { + filesToDelete.add(file); } } - return { filesToEmit, filesToDelete }; + return { + filesToEmit: Array.from(filesToEmit.values(), ({ file }) => file), + filesToDelete: Array.from(filesToDelete), + }; } const resolvedAssets = await resolveAssets(assets, workspaceRoot); + const filesToEmit = new Map(); + for (const { source, destination } of resolvedAssets) { + const outputFile = createDiskOutputFile(source, destination); + filesToEmit.set(outputFile.path, outputFile); + } return { - filesToEmit: resolvedAssets.map(({ source, destination }) => - createDiskOutputFile(source, destination), - ), + filesToEmit: Array.from(filesToEmit.values()), filesToDelete: [], }; } diff --git a/packages/angular/build/src/builders/library/pipeline/assets_spec.ts b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts new file mode 100644 index 000000000000..52a9f11dee53 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { toPosixPath } from '../../../utils/path'; +import { collectAssetsToEmit, createAssetMatchers } from './assets'; + +describe('library pipeline assets', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = toPosixPath(await mkdtemp(path.join(baseTmpDir, 'angular-cli-assets-spec-'))); + }); + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + describe('createAssetMatchers', () => { + it('should normalize patterns, expand directories in ignore, and skip empty strings', () => { + const matchers = createAssetMatchers([ + { + glob: './**/*.txt', + input: `${tempRoot}/assets`, + output: 'assets', + ignore: ['', 'ignored-dir', './ignored-dir-2/', 'specific.txt'], + }, + ]); + + expect(matchers).toHaveSize(1); + const [matcher] = matchers; + + expect(matcher.isMatch('keep.txt')).toBeTrue(); + expect(matcher.isMatch('nested/keep.txt')).toBeTrue(); + expect(matcher.isMatch('.hidden.txt')).toBeTrue(); + expect(matcher.isMatch('specific.txt')).toBeFalse(); + expect(matcher.isMatch('ignored-dir/file.txt')).toBeFalse(); + expect(matcher.isMatch('ignored-dir/sub/file.txt')).toBeFalse(); + expect(matcher.isMatch('ignored-dir-2/file.txt')).toBeFalse(); + expect(matcher.isMatch('.gitkeep')).toBeFalse(); + expect(matcher.isMatch('nested/.DS_Store')).toBeFalse(); + }); + + it('should not throw when glob is an empty string', () => { + const matchers = createAssetMatchers([ + { + glob: '', + input: `${tempRoot}/assets`, + output: 'assets', + }, + ]); + + expect(matchers).toHaveSize(1); + expect(matchers[0].isMatch('any.txt')).toBeFalse(); + }); + }); + + describe('collectAssetsToEmit', () => { + it('should deduplicate resolved assets by destination path with later patterns overriding earlier ones', async () => { + const defaultDir = `${tempRoot}/lib`; + const customDir = `${tempRoot}/docs`; + await mkdir(defaultDir, { recursive: true }); + await mkdir(customDir, { recursive: true }); + + const defaultReadme = `${defaultDir}/README.md`; + const customReadme = `${customDir}/README.md`; + await writeFile(defaultReadme, '# Default'); + await writeFile(customReadme, '# Custom'); + + const assets = [ + { glob: 'README.md', input: defaultDir, output: '.' }, + { glob: 'README.md', input: customDir, output: '.' }, + ]; + + const initialResult = await collectAssetsToEmit(assets, tempRoot, new Map()); + expect(initialResult.filesToEmit).toEqual([ + { + origin: 'disk', + source: customReadme, + path: 'README.md', + }, + ]); + + // Watch mode: even if customReadme appears before defaultReadme in modifiedFiles, + // the later asset pattern (customReadme) should still take precedence. + const watchResult = await collectAssetsToEmit( + assets, + tempRoot, + new Map(), + new Set([customReadme, defaultReadme]), + ); + expect(watchResult.filesToEmit).toEqual([ + { + origin: 'disk', + source: customReadme, + path: 'README.md', + }, + ]); + }); + + it('should respect followSymlinks in watch mode', async () => { + const assetsDir = `${tempRoot}/assets`; + await mkdir(assetsDir, { recursive: true }); + + const targetFile = `${tempRoot}/external.txt`; + const symlinkFile = `${assetsDir}/linked.txt`; + await writeFile(targetFile, 'external'); + await symlink(targetFile, symlinkFile); + + const assetsNoFollow = [ + { glob: '**/*.txt', input: assetsDir, output: 'out', followSymlinks: false }, + ]; + const noFollowResult = await collectAssetsToEmit( + assetsNoFollow, + tempRoot, + new Map(), + new Set([symlinkFile]), + ); + expect(noFollowResult.filesToEmit).toEqual([]); + + const assetsFollow = [ + { glob: '**/*.txt', input: assetsDir, output: 'out', followSymlinks: true }, + ]; + const followResult = await collectAssetsToEmit( + assetsFollow, + tempRoot, + new Map(), + new Set([symlinkFile]), + ); + expect(followResult.filesToEmit).toEqual([ + { + origin: 'disk', + source: symlinkFile, + path: 'out/linked.txt', + }, + ]); + }); + + it('should queue nested emitted assets for deletion when a deleted directory is reported in watch mode', async () => { + const assetsDir = `${tempRoot}/assets`; + const subDir = `${assetsDir}/sub`; + const keepDir = `${assetsDir}/keep-dir`; + await mkdir(keepDir, { recursive: true }); + + const nestedFileA = `${subDir}/a.txt`; + const nestedFileB = `${subDir}/deep/b.txt`; + const keepFile = `${keepDir}/keep.txt`; + await writeFile(keepFile, 'keep'); + + const emittedAssets = new Map>([ + [nestedFileA, new Set(['out/sub/a.txt'])], + [nestedFileB, new Set(['out/sub/deep/b.txt'])], + [keepFile, new Set(['out/keep-dir/keep.txt'])], + ]); + + const assets = [{ glob: '**/*.txt', input: assetsDir, output: 'out' }]; + + // subDir does not exist on disk; keepDir still exists on disk + const result = await collectAssetsToEmit( + assets, + tempRoot, + emittedAssets, + new Set([subDir, keepDir]), + ); + + expect(result.filesToEmit).toEqual([]); + expect(result.filesToDelete).toEqual([nestedFileA, nestedFileB]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts index a596cbfe4e62..f845236bbc05 100644 --- a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts +++ b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { rm, symlink } from 'node:fs/promises'; import { executeLibraryBuilder } from '../../builder'; import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; @@ -53,5 +54,143 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => expect(harness.readFile('dist/lib/docs/README.md')).toBe('# Library Docs'); }); + + it('should allow user assets to override default README.md without duplicate emission', async () => { + await harness.writeFiles({ + 'projects/lib/README.md': '# Default Library README', + 'projects/lib/docs/README.md': '# Custom Library README', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: 'README.md', + input: 'projects/lib/docs', + output: '.', + }, + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/README.md')).toBe('# Custom Library README'); + }); + + it('should not emit symlinked assets in watch mode when followSymlinks is false', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/real.txt': 'real-initial', + 'projects/lib/external.txt': 'external-content', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets', + followSymlinks: false, + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/real.txt')).toBe('real-initial'); + + await symlink( + harness.resolvePath('projects/lib/external.txt'), + harness.resolvePath('projects/lib/assets-dir/linked.txt'), + ); + await harness.writeFile('projects/lib/assets-dir/real.txt', 'real-updated'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/real.txt')).toBe('real-updated'); + expect(harness.hasFile('dist/lib/assets/linked.txt')).toBeFalse(); + }, + ]); + }); + + it('should respect directory ignore patterns in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/keep.txt': 'keep', + 'projects/lib/assets-dir/ignored-dir/initial.txt': 'ignored-initial', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets', + ignore: ['ignored-dir'], + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); + expect(harness.hasFile('dist/lib/assets/ignored-dir/initial.txt')).toBeFalse(); + + await harness.writeFiles({ + 'projects/lib/assets-dir/ignored-dir/added.txt': 'ignored-added', + 'projects/lib/assets-dir/allowed-dir/added.txt': 'allowed-added', + }); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/allowed-dir/added.txt')).toBe('allowed-added'); + expect(harness.hasFile('dist/lib/assets/ignored-dir/added.txt')).toBeFalse(); + }, + ]); + }); + + it('should delete emitted assets when a subdirectory is deleted in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/keep.txt': 'keep', + 'projects/lib/assets-dir/nested/a.txt': 'nested-a', + 'projects/lib/assets-dir/nested/b.txt': 'nested-b', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); + expect(harness.readFile('dist/lib/assets/nested/a.txt')).toBe('nested-a'); + expect(harness.readFile('dist/lib/assets/nested/b.txt')).toBe('nested-b'); + + await rm(harness.resolvePath('projects/lib/assets-dir/nested'), { + recursive: true, + force: true, + }); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); + expect(harness.hasFile('dist/lib/assets/nested/a.txt')).toBeFalse(); + expect(harness.hasFile('dist/lib/assets/nested/b.txt')).toBeFalse(); + }, + ]); + }); }); }); From 91032743a331d82a4fe2eb2fd2e0f0ec74ccd223 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:36:19 +0000 Subject: [PATCH 2/4] fixup! fix(@angular/build): align library watch asset matching with initial build and deduplicate outputs --- packages/angular/build/src/builders/library/pipeline/assets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index ce7930538e8a..618759150d6f 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -47,7 +47,7 @@ export interface AssetMatcher { */ function normalizeGlobPattern(pattern: string): string { let result = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; - if (!result.endsWith('*')) { + if (!picomatch.isGlob(result)) { result += '/**'; } From f016e0a88490e2dd60fe48b4dee68ca5f7304cad Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:03:37 +0000 Subject: [PATCH 3/4] fixup! fix(@angular/build): align library watch asset matching with initial build and deduplicate outputs --- packages/angular/build/src/builders/library/pipeline/assets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 618759150d6f..1a164b1da4a4 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -47,7 +47,7 @@ export interface AssetMatcher { */ function normalizeGlobPattern(pattern: string): string { let result = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; - if (!picomatch.isGlob(result)) { + if (pattern.endsWith('/') || !picomatch.scan(result).isGlob) { result += '/**'; } From b166b6aa542d59f0d1d5bc1b0e526f2cfe952ad2 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:04:06 +0000 Subject: [PATCH 4/4] fixup! fix(@angular/build): align library watch asset matching with initial build and deduplicate outputs --- .../src/builders/library/pipeline/assets.ts | 26 +--- .../library/tests/options/assets_spec.ts | 139 ------------------ 2 files changed, 7 insertions(+), 158 deletions(-) diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 1a164b1da4a4..ae87edb8d4e3 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -40,7 +40,7 @@ export interface AssetMatcher { /** * Normalizes a glob pattern to match `tinyglobby` semantics by stripping trailing slashes, - * expanding directory patterns that do not end in `*` with `/**`, and normalizing POSIX segments. + * expanding directory patterns with `/**`, and normalizing POSIX segments. * * @param pattern The raw glob pattern string. * @returns The normalized POSIX glob pattern. @@ -65,7 +65,7 @@ export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly const ignore = [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])] .filter((pattern) => pattern.length > 0) .map(normalizeGlobPattern); - const isMatch = picomatch(asset.glob ? normalizeGlobPattern(asset.glob) : [], { + const isMatch = picomatch(asset.glob ? path.posix.normalize(asset.glob) : [], { dot: true, windows: false, ignore, @@ -108,28 +108,16 @@ export async function collectAssetsToEmit( if (!emittedAssets.has(file) && emittedAssets.size > 0) { const dirPrefix = file.endsWith('/') ? file : `${file}/`; - let checkedDir = false; - let isDirectory = false; - + let isDeletedDir: boolean | undefined; for (const emittedFile of emittedAssets.keys()) { - if (!emittedFile.startsWith(dirPrefix)) { - continue; - } - - if (!checkedDir) { - checkedDir = true; - const stats = statSync(file, { throwIfNoEntry: false }); - isDirectory = stats?.isDirectory() ?? false; - isFileStat = stats?.isFile() ?? false; - if (isDirectory) { + if (emittedFile.startsWith(dirPrefix)) { + if (!(isDeletedDir ??= !statSync(file, { throwIfNoEntry: false })?.isDirectory())) { break; } + filesToDelete.add(emittedFile); } - - filesToDelete.add(emittedFile); } - - if (checkedDir && (isDirectory || !isFileStat)) { + if (isDeletedDir !== undefined) { continue; } } diff --git a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts index f845236bbc05..a596cbfe4e62 100644 --- a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts +++ b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { rm, symlink } from 'node:fs/promises'; import { executeLibraryBuilder } from '../../builder'; import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; @@ -54,143 +53,5 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => expect(harness.readFile('dist/lib/docs/README.md')).toBe('# Library Docs'); }); - - it('should allow user assets to override default README.md without duplicate emission', async () => { - await harness.writeFiles({ - 'projects/lib/README.md': '# Default Library README', - 'projects/lib/docs/README.md': '# Custom Library README', - }); - - harness.useTarget('build', { - ...BASE_OPTIONS, - assets: [ - { - glob: 'README.md', - input: 'projects/lib/docs', - output: '.', - }, - ], - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); - expect(harness.readFile('dist/lib/README.md')).toBe('# Custom Library README'); - }); - - it('should not emit symlinked assets in watch mode when followSymlinks is false', async () => { - await harness.writeFiles({ - 'projects/lib/assets-dir/real.txt': 'real-initial', - 'projects/lib/external.txt': 'external-content', - }); - - harness.useTarget('build', { - ...BASE_OPTIONS, - assets: [ - { - glob: '**/*.txt', - input: 'projects/lib/assets-dir', - output: 'assets', - followSymlinks: false, - }, - ], - watch: true, - }); - - await harness.executeWithCases([ - async ({ result }) => { - expect(result?.success).toBeTrue(); - expect(harness.readFile('dist/lib/assets/real.txt')).toBe('real-initial'); - - await symlink( - harness.resolvePath('projects/lib/external.txt'), - harness.resolvePath('projects/lib/assets-dir/linked.txt'), - ); - await harness.writeFile('projects/lib/assets-dir/real.txt', 'real-updated'); - }, - ({ result }) => { - expect(result?.success).toBeTrue(); - expect(harness.readFile('dist/lib/assets/real.txt')).toBe('real-updated'); - expect(harness.hasFile('dist/lib/assets/linked.txt')).toBeFalse(); - }, - ]); - }); - - it('should respect directory ignore patterns in watch mode', async () => { - await harness.writeFiles({ - 'projects/lib/assets-dir/keep.txt': 'keep', - 'projects/lib/assets-dir/ignored-dir/initial.txt': 'ignored-initial', - }); - - harness.useTarget('build', { - ...BASE_OPTIONS, - assets: [ - { - glob: '**/*.txt', - input: 'projects/lib/assets-dir', - output: 'assets', - ignore: ['ignored-dir'], - }, - ], - watch: true, - }); - - await harness.executeWithCases([ - async ({ result }) => { - expect(result?.success).toBeTrue(); - expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); - expect(harness.hasFile('dist/lib/assets/ignored-dir/initial.txt')).toBeFalse(); - - await harness.writeFiles({ - 'projects/lib/assets-dir/ignored-dir/added.txt': 'ignored-added', - 'projects/lib/assets-dir/allowed-dir/added.txt': 'allowed-added', - }); - }, - ({ result }) => { - expect(result?.success).toBeTrue(); - expect(harness.readFile('dist/lib/assets/allowed-dir/added.txt')).toBe('allowed-added'); - expect(harness.hasFile('dist/lib/assets/ignored-dir/added.txt')).toBeFalse(); - }, - ]); - }); - - it('should delete emitted assets when a subdirectory is deleted in watch mode', async () => { - await harness.writeFiles({ - 'projects/lib/assets-dir/keep.txt': 'keep', - 'projects/lib/assets-dir/nested/a.txt': 'nested-a', - 'projects/lib/assets-dir/nested/b.txt': 'nested-b', - }); - - harness.useTarget('build', { - ...BASE_OPTIONS, - assets: [ - { - glob: '**/*.txt', - input: 'projects/lib/assets-dir', - output: 'assets', - }, - ], - watch: true, - }); - - await harness.executeWithCases([ - async ({ result }) => { - expect(result?.success).toBeTrue(); - expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); - expect(harness.readFile('dist/lib/assets/nested/a.txt')).toBe('nested-a'); - expect(harness.readFile('dist/lib/assets/nested/b.txt')).toBe('nested-b'); - - await rm(harness.resolvePath('projects/lib/assets-dir/nested'), { - recursive: true, - force: true, - }); - }, - ({ result }) => { - expect(result?.success).toBeTrue(); - expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); - expect(harness.hasFile('dist/lib/assets/nested/a.txt')).toBeFalse(); - expect(harness.hasFile('dist/lib/assets/nested/b.txt')).toBeFalse(); - }, - ]); - }); }); });