From c29df695467c41feccd3846a55c91c6784af87b2 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 15 Nov 2022 13:04:04 +0000 Subject: [PATCH] feat(@angular-devkit/build-angular): add `assets` option to server builder This commits adds the `assets` option to the server builder. This can be useful to copy server specific assets such as config files. Closes #24203 --- .../angular_devkit/build_angular/index.md | 1 + .../src/builders/browser/index.ts | 14 - .../browser/tests/options/assets_spec.ts | 24 +- .../src/builders/server/index.ts | 104 +++-- .../src/builders/server/schema.json | 46 +++ .../server/tests/options/assets_spec.ts | 382 ++++++++++++++++++ .../options/resources-output-path_spec.ts | 8 +- .../src/builders/server/tests/setup.ts | 3 + .../src/utils/normalize-asset-patterns.ts | 12 +- 9 files changed, 528 insertions(+), 66 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/server/tests/options/assets_spec.ts diff --git a/goldens/public-api/angular_devkit/build_angular/index.md b/goldens/public-api/angular_devkit/build_angular/index.md index a5e253c337a2..2a53da0543bd 100644 --- a/goldens/public-api/angular_devkit/build_angular/index.md +++ b/goldens/public-api/angular_devkit/build_angular/index.md @@ -246,6 +246,7 @@ export interface ProtractorBuilderOptions { // @public (undocumented) export interface ServerBuilderOptions { + assets?: AssetPattern_3[]; deleteOutputPath?: boolean; // @deprecated deployUrl?: string; diff --git a/packages/angular_devkit/build_angular/src/builders/browser/index.ts b/packages/angular_devkit/build_angular/src/builders/browser/index.ts index 30bf61c8896c..c7361ffdb311 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/index.ts @@ -111,20 +111,6 @@ async function initialize( getStylesConfig(wco), ]); - // Validate asset option values if processed directly - if (options.assets?.length && !adjustedOptions.assets?.length) { - normalizeAssetPatterns( - options.assets, - context.workspaceRoot, - projectRoot, - projectSourceRoot, - ).forEach(({ output }) => { - if (output.startsWith('..')) { - throw new Error('An asset cannot be written to a location outside of the output path.'); - } - }); - } - let transformedConfig; if (webpackConfigurationTransform) { transformedConfig = await webpackConfigurationTransform(config); 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 0029b6b1e8ca..16394d1f7004 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 @@ -107,7 +107,7 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { harness.expectFile('dist/test.svg').toNotExist(); }); - it('throws exception if asset path is not within project source root', async () => { + it('fail if asset path is not within project source root', async () => { await harness.writeFile('test.svg', ''); harness.useTarget('build', { @@ -115,14 +115,9 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { assets: ['test.svg'], }); - const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + const { result } = await harness.executeOnce(); - expect(result).toBeUndefined(); - expect(error).toEqual( - jasmine.objectContaining({ - message: jasmine.stringMatching('path must start with the project source root'), - }), - ); + expect(result?.error).toMatch('path must start with the project source root'); harness.expectFile('dist/test.svg').toNotExist(); }); @@ -364,7 +359,7 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { harness.expectFile('dist/subdirectory/test.svg').content.toBe(''); }); - it('throws exception if output option is not within project output path', async () => { + it('fails if output option is not within project output path', async () => { await harness.writeFile('test.svg', ''); harness.useTarget('build', { @@ -372,15 +367,10 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { assets: [{ glob: 'test.svg', input: 'src', output: '..' }], }); - const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + const { result } = await harness.executeOnce(); - expect(result).toBeUndefined(); - expect(error).toEqual( - jasmine.objectContaining({ - message: jasmine.stringMatching( - 'An asset cannot be written to a location outside of the output path', - ), - }), + expect(result?.error).toMatch( + 'An asset cannot be written to a location outside of the output path', ); harness.expectFile('dist/test.svg').toNotExist(); diff --git a/packages/angular_devkit/build_angular/src/builders/server/index.ts b/packages/angular_devkit/build_angular/src/builders/server/index.ts index dd38c82af7d2..011a42088805 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/server/index.ts @@ -10,14 +10,22 @@ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/ar import { runWebpack } from '@angular-devkit/build-webpack'; import * as path from 'path'; import { Observable, from } from 'rxjs'; -import { concatMap, map } from 'rxjs/operators'; +import { concatMap } from 'rxjs/operators'; import webpack, { Configuration } from 'webpack'; import { ExecutionTransformer } from '../../transforms'; -import { NormalizedBrowserBuilderSchema, deleteOutputDir } from '../../utils'; +import { + NormalizedBrowserBuilderSchema, + deleteOutputDir, + normalizeAssetPatterns, +} from '../../utils'; +import { colors } from '../../utils/color'; +import { copyAssets } from '../../utils/copy-assets'; +import { assertIsError } from '../../utils/error'; import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining'; import { I18nOptions } from '../../utils/i18n-options'; import { ensureOutputPaths } from '../../utils/output-paths'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; +import { Spinner } from '../../utils/spinner'; import { assertCompatibleAngularVersion } from '../../utils/version'; import { BrowserWebpackConfigOptions, @@ -69,7 +77,7 @@ export function execute( let outputPaths: undefined | Map; return from(initialize(options, context, transforms.webpackConfiguration)).pipe( - concatMap(({ config, i18n }) => { + concatMap(({ config, i18n, projectRoot, projectSourceRoot }) => { return runWebpack(config, context, { webpackFactory: require('webpack') as typeof webpack, logging: (stats, config) => { @@ -84,11 +92,43 @@ export function execute( throw new Error('Webpack stats build result is required.'); } - let success = output.success; - if (success && i18n.shouldInline) { - outputPaths = ensureOutputPaths(baseOutputPath, i18n); + if (!output.success) { + return output; + } - success = await i18nInlineEmittedFiles( + const spinner = new Spinner(); + spinner.enabled = options.progress !== false; + outputPaths = ensureOutputPaths(baseOutputPath, i18n); + + // Copy assets + if (!options.watch && options.assets?.length) { + spinner.start('Copying assets...'); + try { + await copyAssets( + normalizeAssetPatterns( + options.assets, + context.workspaceRoot, + projectRoot, + projectSourceRoot, + ), + Array.from(outputPaths.values()), + context.workspaceRoot, + ); + spinner.succeed('Copying assets complete.'); + } catch (err) { + spinner.fail(colors.redBright('Copying of assets failed.')); + assertIsError(err); + + return { + ...output, + success: false, + error: 'Unable to copy assets: ' + err.message, + }; + } + } + + if (i18n.shouldInline) { + const success = await i18nInlineEmittedFiles( context, emittedFiles, i18n, @@ -98,15 +138,21 @@ export function execute( outputPath, options.i18nMissingTranslation, ); + if (!success) { + return { + ...output, + success: false, + }; + } } webpackStatsLogger(context.logger, webpackStats, config); - return { ...output, success }; + return output; }), ); }), - map((output) => { + concatMap(async (output) => { if (!output.success) { return output as ServerBuilderOutput; } @@ -137,28 +183,34 @@ async function initialize( ): Promise<{ config: webpack.Configuration; i18n: I18nOptions; + projectRoot: string; + projectSourceRoot?: string; }> { // Purge old build disk cache. await purgeStaleBuildCache(context); const browserslist = (await import('browserslist')).default; const originalOutputPath = options.outputPath; - const { config, i18n } = await generateI18nBrowserWebpackConfigFromContext( - { - ...options, - buildOptimizer: false, - aot: true, - platform: 'server', - } as NormalizedBrowserBuilderSchema, - context, - (wco) => { - // We use the platform to determine the JavaScript syntax output. - wco.buildOptions.supportedBrowsers ??= []; - wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions')); - - return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)]; - }, - ); + // Assets are processed directly by the builder except when watching + const adjustedOptions = options.watch ? options : { ...options, assets: [] }; + + const { config, projectRoot, projectSourceRoot, i18n } = + await generateI18nBrowserWebpackConfigFromContext( + { + ...adjustedOptions, + buildOptimizer: false, + aot: true, + platform: 'server', + } as NormalizedBrowserBuilderSchema, + context, + (wco) => { + // We use the platform to determine the JavaScript syntax output. + wco.buildOptions.supportedBrowsers ??= []; + wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions')); + + return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)]; + }, + ); if (options.deleteOutputPath) { deleteOutputDir(context.workspaceRoot, originalOutputPath); @@ -166,7 +218,7 @@ async function initialize( const transformedConfig = (await webpackConfigurationTransform?.(config)) ?? config; - return { config: transformedConfig, i18n }; + return { config: transformedConfig, i18n, projectRoot, projectSourceRoot }; } /** diff --git a/packages/angular_devkit/build_angular/src/builders/server/schema.json b/packages/angular_devkit/build_angular/src/builders/server/schema.json index 73d6088c1f38..8506cff635b3 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/server/schema.json @@ -4,6 +4,14 @@ "title": "Universal Target", "type": "object", "properties": { + "assets": { + "type": "array", + "description": "List of static application assets.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, "main": { "type": "string", "description": "The name of the main entry-point file." @@ -212,6 +220,44 @@ "additionalProperties": false, "required": ["outputPath", "main", "tsConfig"], "definitions": { + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input", "output"] + }, + { + "type": "string" + } + ] + }, "fileReplacement": { "oneOf": [ { diff --git a/packages/angular_devkit/build_angular/src/builders/server/tests/options/assets_spec.ts b/packages/angular_devkit/build_angular/src/builders/server/tests/options/assets_spec.ts new file mode 100644 index 000000000000..1a79f6b79d6a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/server/tests/options/assets_spec.ts @@ -0,0 +1,382 @@ +/** + * @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.io/license + */ + +import { execute } from '../../index'; +import { BASE_OPTIONS, SERVER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(execute, SERVER_BUILDER_INFO, (harness) => { + describe('Option: "assets"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.server.ts', ''); + }); + + it('supports an empty array value', async () => { + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + }); + + it('supports mixing shorthand and longhand syntax', async () => { + await harness.writeFile('src/files/test.svg', ''); + await harness.writeFile('src/files/another.file', 'asset file'); + await harness.writeFile('src/extra.file', 'extra file'); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: ['src/extra.file', { glob: '*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/extra.file').content.toBe('extra file'); + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + describe('shorthand syntax', () => { + it('copies a single asset', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: ['src/test.svg'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('copies multiple assets', async () => { + await harness.writeFile('src/test.svg', ''); + await harness.writeFile('src/another.file', 'asset file'); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: ['src/test.svg', 'src/another.file'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies an asset with directory and maintains directory in output', async () => { + await harness.writeFile('src/subdirectory/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: ['src/subdirectory/test.svg'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/subdirectory/test.svg').content.toBe(''); + }); + + it('does not fail if asset does not exist', async () => { + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: ['src/test.svg'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + + it('fails if output option is not within project output path', async () => { + await harness.writeFile('test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '..' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.error).toMatch( + 'An asset cannot be written to a location outside of the output path', + ); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + }); + + describe('longhand syntax', () => { + it('copies a single asset', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('copies multiple assets as separate entries', async () => { + await harness.writeFile('src/test.svg', ''); + await harness.writeFile('src/another.file', 'asset file'); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [ + { glob: 'test.svg', input: 'src', output: '.' }, + { glob: 'another.file', input: 'src', output: '.' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies multiple assets with a single entry glob pattern', async () => { + await harness.writeFile('src/test.svg', ''); + await harness.writeFile('src/another.file', 'asset file'); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: '{test.svg,another.file}', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies multiple assets with a wildcard glob pattern', async () => { + await harness.writeFile('src/files/test.svg', ''); + await harness.writeFile('src/files/another.file', 'asset file'); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: '*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies multiple assets with a recursive wildcard glob pattern', async () => { + await harness.writeFiles({ + 'src/files/test.svg': '', + 'src/files/another.file': 'asset file', + 'src/files/nested/extra.file': 'extra file', + }); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + harness.expectFile('dist/nested/extra.file').content.toBe('extra file'); + }); + + it('automatically ignores "." prefixed files when using wildcard glob pattern', async () => { + await harness.writeFile('src/files/.gitkeep', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: '*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/.gitkeep').toNotExist(); + }); + + it('supports ignoring a specific file when using a glob pattern', async () => { + await harness.writeFiles({ + 'src/files/test.svg': '', + 'src/files/another.file': 'asset file', + 'src/files/nested/extra.file': 'extra file', + }); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src/files', output: '.', ignore: ['another.file'] }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').toNotExist(); + harness.expectFile('dist/nested/extra.file').content.toBe('extra file'); + }); + + it('supports ignoring with a glob pattern when using a glob pattern', async () => { + await harness.writeFiles({ + 'src/files/test.svg': '', + 'src/files/another.file': 'asset file', + 'src/files/nested/extra.file': 'extra file', + }); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src/files', output: '.', ignore: ['**/*.file'] }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').toNotExist(); + harness.expectFile('dist/nested/extra.file').toNotExist(); + }); + + it('copies an asset with directory and maintains directory in output', async () => { + await harness.writeFile('src/subdirectory/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'subdirectory/test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/subdirectory/test.svg').content.toBe(''); + }); + + it('does not fail if asset does not exist', async () => { + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + + it('uses project output path when output option is empty string', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('uses project output path when output option is "."', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('uses project output path when output option is "/"', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '/' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('creates a project output sub-path when output option path does not exist', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: 'subdirectory' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/subdirectory/test.svg').content.toBe(''); + }); + + it('fails if output option is not within project output path', async () => { + await harness.writeFile('test.svg', ''); + + harness.useTarget('server', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '..' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.error).toMatch( + 'An asset cannot be written to a location outside of the output path', + ); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/server/tests/options/resources-output-path_spec.ts b/packages/angular_devkit/build_angular/src/builders/server/tests/options/resources-output-path_spec.ts index 581207ce4468..42568d4bfb2e 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/tests/options/resources-output-path_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/server/tests/options/resources-output-path_spec.ts @@ -35,10 +35,10 @@ describeBuilder(execute, SERVER_BUILDER_INFO, (harness) => { harness .expectFile('dist/main.js') - .content.toContain(`url(/assets/component-img-absolute.png)`); + .content.toContain(`url('/assets/component-img-absolute.png')`); harness .expectFile('dist/main.js') - .content.toContain(`url(out-assets/component-img-relative.png)`); + .content.toContain(`url('out-assets/component-img-relative.png')`); // Assets are not emitted during a server builds. harness.expectFile('dist/out-assets/component-img-relative.png').toNotExist(); @@ -54,8 +54,8 @@ describeBuilder(execute, SERVER_BUILDER_INFO, (harness) => { harness .expectFile('dist/main.js') - .content.toContain(`url(/assets/component-img-absolute.png)`); - harness.expectFile('dist/main.js').content.toContain(`url(component-img-relative.png)`); + .content.toContain(`url('/assets/component-img-absolute.png')`); + harness.expectFile('dist/main.js').content.toContain(`url('component-img-relative.png')`); // Assets are not emitted during a server builds. harness.expectFile('dist/component-img-relative.png').toNotExist(); diff --git a/packages/angular_devkit/build_angular/src/builders/server/tests/setup.ts b/packages/angular_devkit/build_angular/src/builders/server/tests/setup.ts index e2fa33445afc..21670dc0616d 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/tests/setup.ts +++ b/packages/angular_devkit/build_angular/src/builders/server/tests/setup.ts @@ -25,4 +25,7 @@ export const BASE_OPTIONS = Object.freeze({ progress: false, watch: false, outputPath: 'dist', + + // Disable optimizations + optimization: false, }); diff --git a/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts b/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts index 02a6529d112c..58c0cf5f09a2 100644 --- a/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts +++ b/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts @@ -66,11 +66,13 @@ export function normalizeAssetPatterns( // Output directory for both is the relative path from source root to input. const output = path.relative(resolvedSourceRoot, path.resolve(workspaceRoot, input)); - // Return the asset pattern in object format. - return { glob, input, output }; - } else { - // It's already an AssetPatternObject, no need to convert. - return assetPattern; + assetPattern = { glob, input, output }; } + + if (assetPattern.output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return assetPattern; }); }