From d780fe2d53353f1133eba50d95d283ebf63fb668 Mon Sep 17 00:00:00 2001 From: Mike Brocchi Date: Thu, 10 Aug 2017 13:28:31 -0400 Subject: [PATCH 1/2] feat(@angular/cli): Allow ability to set budget sizes for your bundles Closes #7139 --- packages/@angular/cli/lib/config/schema.json | 16 +++++++++++ packages/@angular/cli/tasks/build.ts | 29 ++++++++++++++++++-- tests/e2e/tests/build/budgets.ts | 22 +++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/tests/build/budgets.ts diff --git a/packages/@angular/cli/lib/config/schema.json b/packages/@angular/cli/lib/config/schema.json index 9a890308dbab..b98ffbf16f0a 100644 --- a/packages/@angular/cli/lib/config/schema.json +++ b/packages/@angular/cli/lib/config/schema.json @@ -192,6 +192,22 @@ "description": "Name and corresponding file for environment config.", "type": "object", "additionalProperties": true + }, + "budgets": { + "description": "Defines the budget allotments for bundles.", + "type": "array", + "items": {"type": "object", + "properties": { + "name": { + "description": "Name of the bundle.", + "type": "string" + }, + "budget": { + "description": "Threshold to measure against. (in kb)", + "type": "number" + } + } + } } }, "additionalProperties": false diff --git a/packages/@angular/cli/tasks/build.ts b/packages/@angular/cli/tasks/build.ts index 1011d5574d7a..625231a49b63 100644 --- a/packages/@angular/cli/tasks/build.ts +++ b/packages/@angular/cli/tasks/build.ts @@ -1,6 +1,7 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import * as webpack from 'webpack'; +import { red } from 'chalk'; import { getAppFromConfig } from '../utilities/app-utils'; import { BuildTaskOptions } from '../commands/build'; @@ -8,6 +9,7 @@ import { NgCliWebpackConfig } from '../models/webpack-config'; import { getWebpackStatsConfig } from '../models/webpack-configs/utils'; import { CliConfig } from '../models/config'; import { statsToString, statsWarningsToString, statsErrorsToString } from '../utilities/stats'; +import { oneLine } from 'common-tags'; const Task = require('../ember-cli/lib/models/task'); const SilentError = require('silent-error'); @@ -17,9 +19,9 @@ export default Task.extend({ run: function (runTaskOptions: BuildTaskOptions) { const config = CliConfig.fromProject().config; - const app = getAppFromConfig(runTaskOptions.app); + const appConfig = getAppFromConfig(runTaskOptions.app); - const outputPath = runTaskOptions.outputPath || app.outDir; + const outputPath = runTaskOptions.outputPath || appConfig.outDir; if (this.project.root === path.resolve(outputPath)) { throw new SilentError('Output path MUST not be project root directory!'); } @@ -30,7 +32,7 @@ export default Task.extend({ fs.removeSync(path.resolve(this.project.root, outputPath)); } - const webpackConfig = new NgCliWebpackConfig(runTaskOptions, app).buildConfig(); + const webpackConfig = new NgCliWebpackConfig(runTaskOptions, appConfig).buildConfig(); const webpackCompiler = webpack(webpackConfig); const statsConfig = getWebpackStatsConfig(runTaskOptions.verbose); @@ -47,6 +49,27 @@ export default Task.extend({ this.ui.writeLine(statsToString(json, statsConfig)); } + if (runTaskOptions.target === 'production' && !!appConfig.budgets) { + let budgetErrors: string[] = []; + appConfig.budgets.forEach((budget: {name: string; budget: number}) => { + const chunk = json.chunks.filter( + (c: { names: string[]; }) => c.names.indexOf(budget.name) !== -1)[0]; + const asset = json.assets.filter((x: any) => x.name == chunk.files[0])[0]; + const size = asset.size / 1000; + if (size > budget.budget) { + budgetErrors.push(oneLine`${budget.name} + budget: ${budget.budget} kB + size: ${size.toPrecision(3)} kB`); + } + }); + if (budgetErrors.length > 0) { + const budgetError = 'Allowed bundle budgets have been exceeded'; + this.ui.writeLine(red(`\n...${budgetError}`)); + budgetErrors.forEach(err => this.ui.writeLine(red(` ${err}`))); + reject(budgetError); + } + } + if (stats.hasWarnings()) { this.ui.writeLine(statsWarningsToString(json, statsConfig)); } diff --git a/tests/e2e/tests/build/budgets.ts b/tests/e2e/tests/build/budgets.ts new file mode 100644 index 000000000000..425ef2dd84e1 --- /dev/null +++ b/tests/e2e/tests/build/budgets.ts @@ -0,0 +1,22 @@ +import {ng} from '../../utils/process'; +import {updateJsonFile} from '../../utils/project'; +import {expectToFail} from '../../utils/utils'; + + +export default function() { + // Skip this in ejected tests. + + // Can't use the `ng` helper because somewhere the environment gets + // stuck to the first build done + return Promise.resolve() + .then(() => updateJsonFile('.angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['budgets'] = [{ name: 'main', budget: 100000 }] + })) + .then(() => ng('build', '--prod')) + .then(() => updateJsonFile('.angular-cli.json', configJson => { + const app = configJson['apps'][0]; + app['budgets'] = [{ name: 'main', budget: 0 }] + })) + .then(() => expectToFail(() => ng('build', '--prod'))); +} From e6601ec830ef4762637db7aa64d8f4f26a049c75 Mon Sep 17 00:00:00 2001 From: Mike Brocchi Date: Tue, 22 Aug 2017 08:28:40 -0400 Subject: [PATCH 2/2] fix(@angular/cli): Working on it... SQUASH THIS COMMIT LATER --- packages/@angular/cli/lib/config/schema.json | 17 ++-- packages/@angular/cli/tasks/build.ts | 50 ++++++----- packages/@angular/cli/utilities/stats.ts | 90 +++++++++++++++++++- tests/e2e/tests/build/budgets.ts | 18 ++-- 4 files changed, 136 insertions(+), 39 deletions(-) diff --git a/packages/@angular/cli/lib/config/schema.json b/packages/@angular/cli/lib/config/schema.json index b98ffbf16f0a..72fd6742c445 100644 --- a/packages/@angular/cli/lib/config/schema.json +++ b/packages/@angular/cli/lib/config/schema.json @@ -196,15 +196,20 @@ "budgets": { "description": "Defines the budget allotments for bundles.", "type": "array", - "items": {"type": "object", + "items": { + "type": "object", "properties": { - "name": { - "description": "Name of the bundle.", - "type": "string" - }, "budget": { - "description": "Threshold to measure against. (in kb)", "type": "number" + }, + "type": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "severity": { + "type": "string" } } } diff --git a/packages/@angular/cli/tasks/build.ts b/packages/@angular/cli/tasks/build.ts index 625231a49b63..7f2e4ab9459b 100644 --- a/packages/@angular/cli/tasks/build.ts +++ b/packages/@angular/cli/tasks/build.ts @@ -1,15 +1,20 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import * as webpack from 'webpack'; -import { red } from 'chalk'; +import { red, yellow } from 'chalk'; import { getAppFromConfig } from '../utilities/app-utils'; import { BuildTaskOptions } from '../commands/build'; import { NgCliWebpackConfig } from '../models/webpack-config'; import { getWebpackStatsConfig } from '../models/webpack-configs/utils'; import { CliConfig } from '../models/config'; -import { statsToString, statsWarningsToString, statsErrorsToString } from '../utilities/stats'; -import { oneLine } from 'common-tags'; +import { + statsToString, + statsWarningsToString, + statsErrorsToString, + evaluateBudgets, + BudgetResult +} from '../utilities/stats'; const Task = require('../ember-cli/lib/models/task'); const SilentError = require('silent-error'); @@ -43,31 +48,30 @@ export default Task.extend({ } const json = stats.toJson('verbose'); + + let budgetResults: BudgetResult[]; + if (runTaskOptions.target === 'production' && !!appConfig.budgets) { + budgetResults = evaluateBudgets(json, appConfig.budgets); + } + if (runTaskOptions.verbose) { this.ui.writeLine(stats.toString(statsConfig)); } else { - this.ui.writeLine(statsToString(json, statsConfig)); + const bundleBudgetResults = budgetResults.filter(br => br.type === 'bundle'); + this.ui.writeLine(statsToString(json, statsConfig, bundleBudgetResults)); } - if (runTaskOptions.target === 'production' && !!appConfig.budgets) { - let budgetErrors: string[] = []; - appConfig.budgets.forEach((budget: {name: string; budget: number}) => { - const chunk = json.chunks.filter( - (c: { names: string[]; }) => c.names.indexOf(budget.name) !== -1)[0]; - const asset = json.assets.filter((x: any) => x.name == chunk.files[0])[0]; - const size = asset.size / 1000; - if (size > budget.budget) { - budgetErrors.push(oneLine`${budget.name} - budget: ${budget.budget} kB - size: ${size.toPrecision(3)} kB`); - } - }); - if (budgetErrors.length > 0) { - const budgetError = 'Allowed bundle budgets have been exceeded'; - this.ui.writeLine(red(`\n...${budgetError}`)); - budgetErrors.forEach(err => this.ui.writeLine(red(` ${err}`))); - reject(budgetError); - } + + const initialBudgetResults = budgetResults.filter(br => br.type === 'initial'); + if (initialBudgetResults.length > 0) { + const prefix = initialBudgetResults.every(b => b.result === 'Warning') + ? 'Warning' : 'Error'; + const color = prefix === 'Warning' ? yellow : red; + this.ui.writeLine(color(`${prefix}: Initial budget size exceeded.`)); + } + + if (budgetResults.filter(br => br.result === 'Error').length > 0) { + reject('Bundle budget exceeded'); } if (stats.hasWarnings()) { diff --git a/packages/@angular/cli/utilities/stats.ts b/packages/@angular/cli/utilities/stats.ts index 87bb31a540c6..40730b3bc606 100644 --- a/packages/@angular/cli/utilities/stats.ts +++ b/packages/@angular/cli/utilities/stats.ts @@ -2,19 +2,35 @@ import { bold, green, red, reset, white, yellow } from 'chalk'; import { stripIndents } from 'common-tags'; +export type BudgetType = 'bundle' | 'initial'; + +export interface Budget { + type: BudgetType; + budget: number; + unit: SizeUnit; + severity: 'Warning' | 'Error'; +} + +export type SizeUnit = 'bytes' | 'kB' | 'MB' | 'GB'; + +const sizeUnits = ['bytes', 'kB', 'MB', 'GB']; + function _formatSize(size: number): string { if (size <= 0) { return '0 bytes'; } - const abbreviations = ['bytes', 'kB', 'MB', 'GB']; const index = Math.floor(Math.log(size) / Math.log(1000)); - return `${+(size / Math.pow(1000, index)).toPrecision(3)} ${abbreviations[index]}`; + return `${+(size / Math.pow(1000, index)).toPrecision(3)} ${sizeUnits[index]}`; } +function _getSize(size: number, unit: SizeUnit): number { + const unitMultiplier = sizeUnits.indexOf(unit); + return size / Math.pow(1000, unitMultiplier); +} -export function statsToString(json: any, statsConfig: any) { +export function statsToString(json: any, statsConfig: any, budgetResults: BudgetResult[] = []) { const colors = statsConfig.colors; const rs = (x: string) => colors ? reset(x) : x; const w = (x: string) => colors ? bold(white(x)) : x; @@ -36,7 +52,22 @@ export function statsToString(json: any, statsConfig: any) { .map(f => f && chunk[f] ? g(` [${f}]`) : '') .join(''); - return `chunk {${y(chunk.id)}} ${g(files)}${names}${size}${parents} ${initial}${flags}`; + let msg = `chunk {${y(chunk.id)}} ${g(files)}${names}${size}${parents} ${initial}${flags}`; + + const chunkWarnings = budgetResults + .filter(bbr => bbr.id === chunk.id && bbr.result === 'Warning'); + const chunkErrors = budgetResults + .filter(bbr => bbr.id === chunk.id && bbr.result === 'Error'); + + const chunkBudgetResults = chunkErrors.length > 0 ? chunkErrors : chunkWarnings; + + chunkBudgetResults.forEach(bbr => { + const color = bbr.result === 'Warning' ? yellow : red; + const budgetMessage = `Bundle budget size exceeded (${bbr.budget} ${bbr.unit})`; + msg += `\n${color(budgetMessage)}`; + }); + + return msg; }).join('\n')} `); } @@ -56,3 +87,54 @@ export function statsErrorsToString(json: any, statsConfig: any) { return rs('\n' + json.errors.map((error: any) => r(`ERROR in ${error}`)).join('\n')); } + +export interface BudgetResult { + result: 'Error' | 'Warning'; + type: BudgetType; + budget: number; + unit: SizeUnit; + id?: number; +} + +export function evaluateBudgets(stats: any, budgets: Budget[]): BudgetResult[] { + const results: BudgetResult[] = []; + + const bundleBudgets = budgets.filter(b => b.type === 'bundle'); + let initialSize = 0; + + stats.chunks.forEach((chunk: any) => { + const asset = stats.assets.filter((x: any) => x.name == chunk.files[0])[0]; + const chunkSize = asset.size; + + initialSize += chunk.initial ? chunkSize : 0; + + if (!chunk.initial) { + bundleBudgets.forEach(budget => { + const chunkSizeInUnit = _getSize(chunkSize, budget.unit); + if (chunkSizeInUnit > budget.budget) { + results.push({ + id: chunk.id, + type: 'bundle', + result: budget.severity, + budget: budget.budget, + unit: budget.unit + }); + } + }); + } + }); + + budgets.filter(b => b.type === 'initial').forEach(budget => { + const initialSizeInUnit = _getSize(initialSize, budget.unit); + if (initialSizeInUnit > budget.budget) { + results.push({ + type: 'initial', + result: budget.severity, + budget: budget.budget, + unit: budget.unit + }); + } + }); + + return results; +} diff --git a/tests/e2e/tests/build/budgets.ts b/tests/e2e/tests/build/budgets.ts index 425ef2dd84e1..34302e37e8d1 100644 --- a/tests/e2e/tests/build/budgets.ts +++ b/tests/e2e/tests/build/budgets.ts @@ -4,19 +4,25 @@ import {expectToFail} from '../../utils/utils'; export default function() { - // Skip this in ejected tests. - - // Can't use the `ng` helper because somewhere the environment gets - // stuck to the first build done return Promise.resolve() .then(() => updateJsonFile('.angular-cli.json', configJson => { const app = configJson['apps'][0]; - app['budgets'] = [{ name: 'main', budget: 100000 }] + app['budgets'] = [{ + type: 'initial', + budget: 1, + unit: 'kB', + severity: 'Warning' + }]; })) .then(() => ng('build', '--prod')) .then(() => updateJsonFile('.angular-cli.json', configJson => { const app = configJson['apps'][0]; - app['budgets'] = [{ name: 'main', budget: 0 }] + app['budgets'] = [{ + type: 'initial', + budget: 1, + unit: 'kB', + severity: 'Error' + }]; })) .then(() => expectToFail(() => ng('build', '--prod'))); }