Skip to content

Size limit support #691

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
15 changes: 11 additions & 4 deletions packages/docs/generate-docs.js
Original file line number Diff line number Diff line change
@@ -46,19 +46,24 @@ const makeType = ({ type, typeName, multiple }) => {
return multiple ? `${typeString}[]` : typeString;
};

/** Determine supported scope for option */
const makeScope = ({ scope }) => {
return scope || 'Global'
}

/** Generate a table for an array of options */
function generateOptionTable(options) {
let text = dedent`\n
| Name | Type | Description |
| ---- | ---- | ----------- |
| Name | Type | Scope | Description |
| ---- | ---- | ----- | ----------- |
`;

Object.entries(options).forEach(([option, value]) => {
if (option.includes('-')) {
return;
}

text += `\n| ${option} | ${makeType(value)} | ${value.description} |`;
text += `\n| ${option} | ${makeType(value)} | ${makeScope(value)} | ${value.description} |`;
});

return text;
@@ -82,7 +87,7 @@ async function generateConfigDocs() {

\`@design-systems/cli\` supports a wide array of configuration files.

Add one of the following to to the root of the project:
Add one of the following to to the root of the project and/or the root of a given submodule:

- a \`ds\` key in the \`package.json\`
- \`.dsrc\`
@@ -93,6 +98,8 @@ async function generateConfigDocs() {
- \`ds.config.js\`
- \`ds.config.json\`

!> The package-specific configuration feature is in very early stages, and only supports options with the **Local** scope.

## Structure

The config is structured with each key being a command name and the value being an object configuring options.
2 changes: 2 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -9,6 +9,8 @@ import { Overwrite } from 'utility-types';
export type Option = AppOption & {
/** Whether the Option should be configurable via ds.config.json */
config?: boolean;
/** Whether or not the option is available in the global or local scope */
scope?: string;
};

interface Configurable {
2 changes: 2 additions & 0 deletions plugins/size/package.json
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@
},
"dependencies": {
"@design-systems/cli-utils": "link:../../packages/cli-utils",
"cosmiconfig": "7.0.0",
"@design-systems/plugin": "link:../../packages/plugin",
"@royriojas/get-exports-from-file": "https://github.com/hipstersmoothie/get-exports-from-file#all",
"change-case": "4.1.1",
@@ -42,6 +43,7 @@
"table": "6.0.7",
"terser-webpack-plugin": "4.1.0",
"tslib": "2.0.1",
"utility-types": "3.10.0",
"webpack": "4.44.1",
"webpack-bundle-analyzer": "3.8.0",
"webpack-inject-plugin": "1.5.5",
7 changes: 7 additions & 0 deletions plugins/size/src/command.ts
Original file line number Diff line number Diff line change
@@ -89,6 +89,13 @@ const command: CliCommand = {
description: 'Failure Threshold for Size',
config: true
},
{
name: 'sizeLimit',
type: Number,
description: 'Size limit failure threshold',
config: true,
scope: 'Local'
},
{
name: 'merge-base',
type: String,
7 changes: 4 additions & 3 deletions plugins/size/src/index.ts
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import {
SizeResult} from "./interfaces"
import { formatLine, formatExports } from "./utils/formatUtils";
import { buildPackages } from "./utils/BuildUtils";
import { calcSizeForAllPackages, reportResults, table, diffSizeForPackage } from "./utils/CalcSizeUtils";
import { calcSizeForAllPackages, reportResults, table, diffSizeForPackage, sizePassesMuster } from "./utils/CalcSizeUtils";
import { startAnalyze } from "./utils/WebpackUtils";
import { createDiff } from "./utils/DiffUtils";

@@ -86,10 +86,11 @@ export default class SizePlugin implements Plugin<SizeArgs> {
local
});
const header = args.css ? cssHeader : defaultHeader;
const success = sizePassesMuster(size, FAILURE_THRESHOLD);

await reportResults(
name,
size.percent <= FAILURE_THRESHOLD || size.percent === Infinity,
success,
Boolean(args.comment),
table(
args.detailed
@@ -107,7 +108,7 @@ export default class SizePlugin implements Plugin<SizeArgs> {
createDiff();
}

if (size && size.percent > FAILURE_THRESHOLD && size.percent !== Infinity) {
if (!success) {
process.exit(1);
}
}
15 changes: 15 additions & 0 deletions plugins/size/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -19,6 +19,8 @@ export interface SizeArgs {
ignore?: string[]
/** The registry to install packages from */
registry?: string
/** Size limit failure threshold */
sizeLimit?: number
/** Size Failure Threshold */
failureThreshold?: number
/** Run the plugin against merge base. (Will be slower due to additional build process) */
@@ -43,6 +45,8 @@ export interface Size {
js: number
/** Top level exports of package */
exported?: Export[]
/** Maximum bundle size as defined by the package */
limit?: number
}

export interface SizeResult {
@@ -52,6 +56,8 @@ export interface SizeResult {
pr: Size
/** The difference between sizes */
percent: number
/** The total number of bytes allowed as defined in the local changeset */
localBudget?: number
}

export interface ConfigOptions {
@@ -88,6 +94,15 @@ export interface GetSizesOptions extends CommonCalcSizeOptions {
analyze?: boolean
/** What port to start the analyzer on */
analyzerPort?: number
/** Working directory to execute analysis from */
dir: string
}

export interface LoadPackageOptions {
/** The name of the package to get size for */
name: string
/** The registry to install packages from */
registry?: string
}

type Scope = 'pr' | 'master'
45 changes: 42 additions & 3 deletions plugins/size/src/utils/BuildUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { execSync } from 'child_process';
import { execSync, ExecSyncOptions } from 'child_process';
import os from 'os';
import path from 'path';
import { getMonorepoRoot, createLogger } from '@design-systems/cli-utils';
import fs from 'fs-extra';
import { getMonorepoRoot, createLogger, getLogLevel } from '@design-systems/cli-utils';
import { mockPackage } from './CalcSizeUtils';
import { LoadPackageOptions } from '../interfaces';

const logger = createLogger({ scope: 'size' });

@@ -11,7 +14,7 @@ export function buildPackages(args: {
mergeBase: string
/** Build command for merge base */
buildCommand: string
}) {
}): string {
const id = Math.random().toString(36).substring(7);
const dir = path.join(os.tmpdir(), `commit-build-${id}`);
const root = getMonorepoRoot();
@@ -54,3 +57,39 @@ export function getLocalPackage(

return path.join(local, path.relative(getMonorepoRoot(), pkg.location));
}

/** Install package to tmp dir */
export async function loadPackage(options: LoadPackageOptions): Promise<string> {
const dir = mockPackage();
const execOptions: ExecSyncOptions = {
cwd: dir,
stdio: getLogLevel() === 'trace' ? 'inherit' : 'ignore'
};
try {
const browsersList = path.join(getMonorepoRoot(), '.browserslistrc');
if (fs.existsSync(browsersList)) {
fs.copyFileSync(browsersList, path.join(dir, '.browserslistrc'));
}

const npmrc = path.join(getMonorepoRoot(), '.npmrc');
if (options.registry && fs.existsSync(npmrc)) {
fs.copyFileSync(npmrc, path.join(dir, '.npmrc'));
}

logger.debug(`Installing: ${options.name}`);
if (options.registry) {
execSync(
`yarn add ${options.name} --registry ${options.registry}`,
execOptions
);
} else {
execSync(`yarn add ${options.name}`, execOptions);
}
} catch (error) {
logger.debug(error);
logger.warn(`Could not find package ${options.name}...`);
return './';
}

return dir;
}
55 changes: 47 additions & 8 deletions plugins/size/src/utils/CalcSizeUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { monorepoName, createLogger } from '@design-systems/cli-utils';
import { cosmiconfigSync as load } from 'cosmiconfig';
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 would prefer to use @design-systems/load-config here, but trying to link that resulted in a weird circular dependency issue that broke installation (see previous build). Got tired of fighting with that, but if someone else wants to tackle that optimization then by all means.

import path from 'path';
import fs from 'fs-extra';
import os from 'os';
@@ -20,7 +21,7 @@ import {
DiffSizeForPackageOptions
} from '../interfaces';
import { getSizes } from './WebpackUtils';
import { getLocalPackage } from './BuildUtils';
import { getLocalPackage, loadPackage } from './BuildUtils';

const RUNTIME_SIZE = 537;

@@ -39,6 +40,22 @@ const cssHeader = [

const defaultHeader = ['master', 'pr', '+/-', '%'];

/** Load package-specific configuration options. */
function loadConfig(cwd: string) {
return load('ds', {
searchPlaces: [
'package.json',
`.dsrc`,
`.dsrc.json`,
`.dsrc.yaml`,
`.dsrc.yml`,
`.dsrc.js`,
`ds.config.js`,
`ds.config.json`,
]
}).search(cwd)?.config;
}

/** Calculate the bundled CSS and JS size. */
async function calcSizeForPackage({
name,
@@ -50,15 +67,24 @@ async function calcSizeForPackage({
registry,
local
}: CommonOptions & CommonCalcSizeOptions): Promise<Size> {
const packageName = local ? getLocalPackage(importName, local) : name;
const dir = await loadPackage({
name: packageName,
registry
});
const sizes = await getSizes({
name: local ? getLocalPackage(importName, local) : name,
name: packageName,
importName,
scope,
persist,
chunkByExport,
diff,
registry
registry,
dir
});
const packageDir = local ? path.join(dir, 'node_modules', packageName) : name;
const packageConfig = loadConfig(packageDir);
fs.removeSync(dir);

const js = sizes.filter((size) => !size.chunkNames.includes('css'));
const css = sizes.filter((size) => size.chunkNames.includes('css'));
@@ -76,6 +102,7 @@ async function calcSizeForPackage({
js: js.length ? js.reduce((acc, i) => i.size + acc, 0) - RUNTIME_SIZE : 0, // Minus webpack runtime size;
css: css.length ? css.reduce((acc, i) => i.size + acc, 0) : 0,
exported: sizes,
limit: packageConfig?.size?.sizeLimit
};
}

@@ -129,11 +156,12 @@ async function diffSizeForPackage({
master,
pr,
percent,
localBudget: pr.limit
};
}

/** Create a mock npm package in a tmp dir on the system. */
export function mockPackage() {
export function mockPackage(): string {
const id = Math.random().toString(36).substring(7);
const dir = path.join(os.tmpdir(), `package-size-${id}`);

@@ -267,6 +295,15 @@ function table(data: (string | number)[][], isCi?: boolean) {
return cliTable(data);
}

/** Analyzes a SizeResult to determine if it passes or fails */
export function sizePassesMuster(size: SizeResult, failureThreshold: number) {
const underFailureThreshold = size &&
size.percent <= failureThreshold ||
size.percent === Infinity;
const underSizeLimit = size.localBudget ? size.pr.js + size.pr.css <= size.localBudget : true;
return underFailureThreshold && underSizeLimit;
}

/** Generate diff for all changed packages in the monorepo. */
async function calcSizeForAllPackages(args: SizeArgs & CommonCalcSizeOptions) {
const ignore = args.ignore || [];
@@ -317,11 +354,13 @@ async function calcSizeForAllPackages(args: SizeArgs & CommonCalcSizeOptions) {
results.push(size);

const FAILURE_THRESHOLD = args.failureThreshold || 5;
if (size.percent > FAILURE_THRESHOLD && size.percent !== Infinity) {
success = false;
logger.error(`${packageJson.package.name} failed bundle size check :(`);
} else {

success = sizePassesMuster(size, FAILURE_THRESHOLD);

if (success) {
logger.success(`${packageJson.package.name} passed bundle size check!`);
} else {
logger.error(`${packageJson.package.name} failed bundle size check :(`);
}

return args.detailed
63 changes: 17 additions & 46 deletions plugins/size/src/utils/WebpackUtils.ts
Original file line number Diff line number Diff line change
@@ -9,13 +9,11 @@ import Terser from 'terser-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { getMonorepoRoot, getLogLevel } from '@design-systems/cli-utils';
import { execSync, ExecSyncOptions } from 'child_process';
import { execSync } from 'child_process';
import RelativeCommentsPlugin from '../RelativeCommentsPlugin';
import { fromEntries } from './formatUtils';
import { ConfigOptions, GetSizesOptions, CommonOptions } from '../interfaces';
import { mockPackage } from './CalcSizeUtils';
import { getLocalPackage } from './BuildUtils';
import { getLocalPackage, loadPackage } from './BuildUtils';

const logger = createLogger({ scope: 'size' });

@@ -161,52 +159,18 @@ async function runWebpack(config: webpack.Configuration): Promise<webpack.Stats>
});
}

/** Install package to tmp dir and run webpack on it to calculate size. */
/** Run webpack on package directory to calculate size. */
async function getSizes(options: GetSizesOptions & CommonOptions) {
const dir = mockPackage();
const execOptions: ExecSyncOptions = {
cwd: dir,
stdio: getLogLevel() === 'trace' ? 'inherit' : 'ignore'
};
try {
const browsersList = path.join(getMonorepoRoot(), '.browserslistrc');
if (fs.existsSync(browsersList)) {
fs.copyFileSync(browsersList, path.join(dir, '.browserslistrc'));
}

const npmrc = path.join(getMonorepoRoot(), '.npmrc');
if (options.registry && fs.existsSync(npmrc)) {
fs.copyFileSync(npmrc, path.join(dir, '.npmrc'));
}

logger.debug(`Installing: ${options.name}`);
if (options.registry) {
execSync(
`yarn add ${options.name} --registry ${options.registry}`,
execOptions
);
} else {
execSync(`yarn add ${options.name}`, execOptions);
}
} catch (error) {
logger.debug(error);
logger.warn(`Could not find package ${options.name}...`);
return [];
}

const result = await runWebpack(
await config({
dir,
...options
})
await config(options)
);
logger.debug(`Completed building: ${dir}`);
logger.debug(`Completed building: ${options.dir}`);
if (options.persist) {
const folder = `bundle-${options.scope}-${options.importName}`;
const out = path.join(process.cwd(), folder);
logger.info(`Persisting output to: ${folder}`);
await fs.remove(out);
await fs.copy(dir, out);
await fs.copy(options.dir, out);
await fs.writeFile(`${out}/stats.json`, JSON.stringify(result.toJson()));
await fs.writeFile(
`${out}/.gitignore`,
@@ -218,7 +182,6 @@ async function getSizes(options: GetSizesOptions & CommonOptions) {
execSync('git commit -m "init"', { cwd: out });
}

fs.removeSync(dir);
if (result.hasErrors()) {
throw new Error(result.toString('errors-only'));
}
@@ -234,23 +197,31 @@ async function getSizes(options: GetSizesOptions & CommonOptions) {
/** Start the webpack bundle analyzer for both of the bundles. */
async function startAnalyze(name: string, registry?: string, local?: string) {
logger.start('Analyzing build output...');
const packageName = local ? getLocalPackage(name, local) : name;
const dir = await loadPackage({
name: packageName,
registry
});
await Promise.all([
getSizes({
name: local ? getLocalPackage(name, local) : name,
name: packageName,
importName: name,
scope: 'master',
analyze: true,
registry
registry,
dir
}),
getSizes({
name: process.cwd(),
importName: name,
scope: 'pr',
analyze: true,
analyzerPort: 9000,
registry
registry,
dir
})
]);
fs.removeSync(dir);
}

export { startAnalyze, runWebpack, config, getSizes };