@mui/internal-bundle-size-checker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +14 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/bin/bundle-size-checker.js +3 -0
- package/package.json +45 -0
- package/src/browser.d.ts +2 -0
- package/src/browser.js +2 -0
- package/src/cli.js +448 -0
- package/src/configLoader.js +189 -0
- package/src/defineConfig.js +15 -0
- package/src/fetchSnapshot.js +36 -0
- package/src/formatUtils.js +28 -0
- package/src/index.js +20 -0
- package/src/renderMarkdownReport.js +200 -0
- package/src/sizeDiff.js +199 -0
- package/src/types.d.ts +106 -0
- package/src/uploadSnapshot.js +72 -0
- package/src/worker.js +332 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility to load the bundle-size-checker configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import envCi from 'env-ci';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Attempts to load and parse a single config file
|
|
11
|
+
* @param {string} configPath - Path to the configuration file
|
|
12
|
+
* @returns {Promise<BundleSizeCheckerConfig | null>} The parsed config or null if file doesn't exist
|
|
13
|
+
* @throws {Error} If the file exists but has invalid format
|
|
14
|
+
*/
|
|
15
|
+
async function loadConfigFile(configPath) {
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.existsSync(configPath)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Dynamic import for ESM
|
|
22
|
+
const configUrl = new URL(`file://${configPath}`);
|
|
23
|
+
let { default: config } = await import(configUrl.href);
|
|
24
|
+
|
|
25
|
+
// Handle configs that might be Promise-returning functions
|
|
26
|
+
if (config instanceof Promise) {
|
|
27
|
+
config = await config;
|
|
28
|
+
} else if (typeof config === 'function') {
|
|
29
|
+
config = await config();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!config.entrypoints || !Array.isArray(config.entrypoints)) {
|
|
33
|
+
throw new Error('Configuration must include an entrypoints array');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return config;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(`Error loading config from ${configPath}:`, error);
|
|
39
|
+
throw error; // Re-throw to indicate failure
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validates and normalizes an upload configuration object
|
|
45
|
+
* @param {UploadConfig} uploadConfig - The upload configuration to normalize
|
|
46
|
+
* @param {Object} ciInfo - CI environment information
|
|
47
|
+
* @param {string} [ciInfo.branch] - Branch name from CI environment
|
|
48
|
+
* @param {boolean} [ciInfo.isPr] - Whether this is a pull request from CI environment
|
|
49
|
+
* @param {string} [ciInfo.prBranch] - PR branch name from CI environment
|
|
50
|
+
* @param {string} [ciInfo.slug] - Repository slug from CI environment
|
|
51
|
+
* @returns {NormalizedUploadConfig} - Normalized upload config
|
|
52
|
+
* @throws {Error} If required fields are missing
|
|
53
|
+
*/
|
|
54
|
+
export function applyUploadConfigDefaults(uploadConfig, ciInfo) {
|
|
55
|
+
const { slug, branch: ciBranch, isPr, prBranch } = ciInfo;
|
|
56
|
+
|
|
57
|
+
// Get repo from config or environment
|
|
58
|
+
const repo = uploadConfig.repo || slug;
|
|
59
|
+
if (!repo) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'Missing required field: upload.repo. Please specify a repository (e.g., "mui/material-ui").',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Get branch from config or environment
|
|
66
|
+
const branch = uploadConfig.branch || (isPr ? prBranch : ciBranch);
|
|
67
|
+
if (!branch) {
|
|
68
|
+
throw new Error('Missing required field: upload.branch. Please specify a branch name.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Return the normalized config
|
|
72
|
+
return {
|
|
73
|
+
repo,
|
|
74
|
+
branch,
|
|
75
|
+
isPullRequest:
|
|
76
|
+
uploadConfig.isPullRequest !== undefined
|
|
77
|
+
? Boolean(uploadConfig.isPullRequest)
|
|
78
|
+
: Boolean(isPr),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Apply default values to the configuration using CI environment
|
|
84
|
+
* @param {BundleSizeCheckerConfig} config - The loaded configuration
|
|
85
|
+
* @returns {NormalizedBundleSizeCheckerConfig} Configuration with defaults applied
|
|
86
|
+
* @throws {Error} If required fields are missing
|
|
87
|
+
*/
|
|
88
|
+
function applyConfigDefaults(config) {
|
|
89
|
+
// Get environment CI information
|
|
90
|
+
/** @type {{ branch?: string, isPr?: boolean, prBranch?: string, slug?: string}} */
|
|
91
|
+
const ciInfo = envCi();
|
|
92
|
+
|
|
93
|
+
// Basic validation to ensure entries have the required structure
|
|
94
|
+
// More detailed validation will be done in the worker
|
|
95
|
+
for (const entry of config.entrypoints) {
|
|
96
|
+
if (typeof entry !== 'string' && (!entry || typeof entry !== 'object')) {
|
|
97
|
+
throw new Error('Each entry must be either a string or an object');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Clone the config to avoid mutating the original
|
|
102
|
+
/** @type {NormalizedBundleSizeCheckerConfig} */
|
|
103
|
+
const result = {
|
|
104
|
+
entrypoints: config.entrypoints.map((entry, i) => {
|
|
105
|
+
if (typeof entry === 'string') {
|
|
106
|
+
// Transform string entries into object entries
|
|
107
|
+
const [importSrc, importName] = entry.split('#');
|
|
108
|
+
if (importName) {
|
|
109
|
+
// For entries like '@mui/material#Button', create an object with import and importedNames
|
|
110
|
+
return {
|
|
111
|
+
id: entry,
|
|
112
|
+
import: importSrc,
|
|
113
|
+
importedNames: [importName],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// For entries like '@mui/material', create an object with import only
|
|
117
|
+
return {
|
|
118
|
+
id: entry,
|
|
119
|
+
import: importSrc,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (entry && typeof entry === 'object') {
|
|
124
|
+
// For existing object entries, return them as is
|
|
125
|
+
return entry;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Invalid entry format config.entrypoints[${i}]. Must be a string or an object.`,
|
|
130
|
+
);
|
|
131
|
+
}),
|
|
132
|
+
upload: null, // Default to disabled
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Handle different types of upload value
|
|
136
|
+
if (typeof config.upload === 'boolean') {
|
|
137
|
+
// If upload is false, leave as null
|
|
138
|
+
if (config.upload === false) {
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// If upload is true, create empty object and apply defaults
|
|
143
|
+
if (!ciInfo.slug) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
'Upload enabled but repository not found in CI environment. Please specify upload.repo in config.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!ciInfo.branch && !(ciInfo.isPr && ciInfo.prBranch)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
'Upload enabled but branch not found in CI environment. Please specify upload.branch in config.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Apply defaults to an empty object
|
|
156
|
+
result.upload = applyUploadConfigDefaults({}, ciInfo);
|
|
157
|
+
} else if (config.upload) {
|
|
158
|
+
// It's an object, apply defaults
|
|
159
|
+
result.upload = applyUploadConfigDefaults(config.upload, ciInfo);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Attempts to load the config file from the given directory
|
|
167
|
+
* @param {string} rootDir - The directory to search for the config file
|
|
168
|
+
* @returns {Promise<NormalizedBundleSizeCheckerConfig>} A promise that resolves to the normalized config object
|
|
169
|
+
*/
|
|
170
|
+
export async function loadConfig(rootDir) {
|
|
171
|
+
const configPaths = [
|
|
172
|
+
path.join(rootDir, 'bundle-size-checker.config.js'),
|
|
173
|
+
path.join(rootDir, 'bundle-size-checker.config.mjs'),
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
for (const configPath of configPaths) {
|
|
177
|
+
// eslint-disable-next-line no-await-in-loop
|
|
178
|
+
const config = await loadConfigFile(configPath);
|
|
179
|
+
if (config) {
|
|
180
|
+
// Apply defaults and return the config
|
|
181
|
+
return applyConfigDefaults(config);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Error out if no config file exists
|
|
186
|
+
throw new Error(
|
|
187
|
+
'No bundle-size-checker configuration file found. Please create a bundle-size-checker.config.js or bundle-size-checker.config.mjs file in your project root.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} BundleSizeCheckerConfig
|
|
3
|
+
* @property {string[]} entrypoints - Array of entrypoints to check size for
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Define a configuration for the bundle size checker.
|
|
8
|
+
* This is just a pass-through function for better TypeScript typing.
|
|
9
|
+
*
|
|
10
|
+
* @param {BundleSizeCheckerConfig} config - Configuration object
|
|
11
|
+
* @returns {BundleSizeCheckerConfig} The configuration object
|
|
12
|
+
*/
|
|
13
|
+
export default function defineConfig(config) {
|
|
14
|
+
return config;
|
|
15
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @param {string} repo - The name of the repository e.g. 'mui/material-ui'
|
|
4
|
+
* @param {string} sha - The commit SHA
|
|
5
|
+
* @returns {Promise<import('./sizeDiff').SizeSnapshot>} - The size snapshot data
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchSnapshot(repo, sha) {
|
|
8
|
+
const urlsToTry = [
|
|
9
|
+
`https://s3.eu-central-1.amazonaws.com/mui-org-ci/artifacts/${repo}/${sha}/size-snapshot.json`,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
if (repo === 'mui/material-ui') {
|
|
13
|
+
urlsToTry.push(
|
|
14
|
+
`https://s3.eu-central-1.amazonaws.com/mui-org-ci/artifacts/master/${sha}/size-snapshot.json`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let lastError;
|
|
19
|
+
for (const url of urlsToTry) {
|
|
20
|
+
try {
|
|
21
|
+
// eslint-disable-next-line no-await-in-loop
|
|
22
|
+
const response = await fetch(url);
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
lastError = new Error(`Failed to fetch "${url}", HTTP ${response.status}`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return response.json();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
lastError = error;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
throw new Error(`Failed to fetch snapshot`, { cause: lastError });
|
|
36
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format utilities for consistent display of sizes and percentages
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Formatter for byte sizes (absolute values) - no sign
|
|
6
|
+
export const byteSizeFormatter = new Intl.NumberFormat(undefined, {
|
|
7
|
+
style: 'unit',
|
|
8
|
+
unit: 'byte',
|
|
9
|
+
notation: 'compact',
|
|
10
|
+
unitDisplay: 'narrow',
|
|
11
|
+
maximumSignificantDigits: 3,
|
|
12
|
+
minimumSignificantDigits: 1,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Formatter for size changes - always show sign
|
|
16
|
+
export const byteSizeChangeFormatter = new Intl.NumberFormat(undefined, {
|
|
17
|
+
...byteSizeFormatter.resolvedOptions(),
|
|
18
|
+
signDisplay: 'exceptZero',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Formatter for percentage display
|
|
22
|
+
export const displayPercentFormatter = new Intl.NumberFormat(undefined, {
|
|
23
|
+
style: 'percent',
|
|
24
|
+
signDisplay: 'exceptZero',
|
|
25
|
+
minimumFractionDigits: 2,
|
|
26
|
+
maximumFractionDigits: 2,
|
|
27
|
+
useGrouping: true,
|
|
28
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import defineConfig from './defineConfig.js';
|
|
2
|
+
import { loadConfig } from './configLoader.js';
|
|
3
|
+
import { calculateSizeDiff } from './sizeDiff.js';
|
|
4
|
+
import { renderMarkdownReport, renderMarkdownReportContent } from './renderMarkdownReport.js';
|
|
5
|
+
import { fetchSnapshot } from './fetchSnapshot.js';
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
defineConfig,
|
|
9
|
+
loadConfig,
|
|
10
|
+
calculateSizeDiff,
|
|
11
|
+
renderMarkdownReport,
|
|
12
|
+
renderMarkdownReportContent,
|
|
13
|
+
fetchSnapshot,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import('./sizeDiff.js').Size} Size
|
|
18
|
+
* @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
|
|
19
|
+
* @typedef {import('./sizeDiff.js').ComparisonResult} ComparisonResult
|
|
20
|
+
*/
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('./sizeDiff.js').Size} Size
|
|
3
|
+
* @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
|
|
4
|
+
* @typedef {import('./sizeDiff.js').ComparisonResult} ComparisonResult
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { calculateSizeDiff } from './sizeDiff.js';
|
|
8
|
+
import { fetchSnapshot } from './fetchSnapshot.js';
|
|
9
|
+
import { displayPercentFormatter, byteSizeChangeFormatter } from './formatUtils.js';
|
|
10
|
+
/**
|
|
11
|
+
*
|
|
12
|
+
* @param {'▲' | '▼'} symbol
|
|
13
|
+
* @param {'yellow'|'red'|'blue'|'green'} color
|
|
14
|
+
* @returns
|
|
15
|
+
*/
|
|
16
|
+
function formatSymbol(symbol, color) {
|
|
17
|
+
return `<sup>\${\\tiny{\\color{${color}}${symbol}}}$</sup>`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generates a symbol based on the relative change value.
|
|
22
|
+
* @param {number|null} relative - The relative change as a Number
|
|
23
|
+
* @returns {string} Formatted size change string with symbol
|
|
24
|
+
*/
|
|
25
|
+
function getChangeIcon(relative) {
|
|
26
|
+
if (relative === null) {
|
|
27
|
+
return formatSymbol('▲', 'yellow');
|
|
28
|
+
}
|
|
29
|
+
if (relative === -1) {
|
|
30
|
+
return formatSymbol('▼', 'blue');
|
|
31
|
+
}
|
|
32
|
+
if (relative < 0) {
|
|
33
|
+
return formatSymbol('▼', 'green');
|
|
34
|
+
}
|
|
35
|
+
if (relative > 0) {
|
|
36
|
+
return formatSymbol('▲', 'red');
|
|
37
|
+
}
|
|
38
|
+
return ' ';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Formats the relative change value for display.
|
|
43
|
+
* @param {number|null} value - The relative change as a Number
|
|
44
|
+
* @returns {string} Formatted relative change string
|
|
45
|
+
*/
|
|
46
|
+
function formatRelativeChange(value) {
|
|
47
|
+
if (value === null) {
|
|
48
|
+
return 'new';
|
|
49
|
+
}
|
|
50
|
+
if (value === -1) {
|
|
51
|
+
return 'removed';
|
|
52
|
+
}
|
|
53
|
+
return displayPercentFormatter.format(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Generates a user-readable string from a percentage change.
|
|
58
|
+
* @param {number} absolute - The absolute change as a Number
|
|
59
|
+
* @param {number|null} relative - The relative change as a Number
|
|
60
|
+
* @returns {string} Formatted percentage string with emoji
|
|
61
|
+
*/
|
|
62
|
+
function formatChange(absolute, relative) {
|
|
63
|
+
const formattedAbsolute = byteSizeChangeFormatter.format(absolute);
|
|
64
|
+
const formattedChange = formatRelativeChange(relative);
|
|
65
|
+
return `${getChangeIcon(relative)}${formattedAbsolute}<sup>(${formattedChange})</sup>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generates emphasized change text for a single bundle
|
|
70
|
+
* @param {Size} entry - Bundle entry
|
|
71
|
+
* @returns {string} Formatted change text
|
|
72
|
+
*/
|
|
73
|
+
function generateEmphasizedChange({ id: bundle, parsed, gzip }) {
|
|
74
|
+
// increase might be a bug fix which is a nice thing. reductions are always nice
|
|
75
|
+
const changeParsed = formatChange(parsed.absoluteDiff, parsed.relativeDiff);
|
|
76
|
+
const changeGzip = formatChange(gzip.absoluteDiff, gzip.relativeDiff);
|
|
77
|
+
|
|
78
|
+
return `**${bundle}** **parsed:**${changeParsed} **gzip:**${changeGzip}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generates a Markdown report for bundle size changes
|
|
83
|
+
* @param {ComparisonResult} comparison - Comparison result from calculateSizeDiff
|
|
84
|
+
* @param {Object} [options] - Additional options
|
|
85
|
+
* @param {number} [options.visibleLimit=10] - Number of entries to show before collapsing
|
|
86
|
+
* @param {number} [options.parsedSizeChangeThreshold=300] - Threshold for parsed size change by which to show the entry
|
|
87
|
+
* @param {number} [options.gzipSizeChangeThreshold=100] - Threshold for gzipped size change by which to show the entry
|
|
88
|
+
* @returns {string} Markdown report
|
|
89
|
+
*/
|
|
90
|
+
export function renderMarkdownReportContent(
|
|
91
|
+
comparison,
|
|
92
|
+
{ visibleLimit = 10, parsedSizeChangeThreshold = 300, gzipSizeChangeThreshold = 100 } = {},
|
|
93
|
+
) {
|
|
94
|
+
let markdownContent = '';
|
|
95
|
+
|
|
96
|
+
markdownContent += `**Total Size Change:**${formatChange(
|
|
97
|
+
comparison.totals.totalParsed,
|
|
98
|
+
comparison.totals.totalParsedPercent,
|
|
99
|
+
)} - **Total Gzip Change:**${formatChange(
|
|
100
|
+
comparison.totals.totalGzip,
|
|
101
|
+
comparison.totals.totalGzipPercent,
|
|
102
|
+
)}\n`;
|
|
103
|
+
|
|
104
|
+
markdownContent += `Files: ${comparison.fileCounts.total} total (${
|
|
105
|
+
comparison.fileCounts.added
|
|
106
|
+
} added, ${comparison.fileCounts.removed} removed, ${comparison.fileCounts.changed} changed)\n\n`;
|
|
107
|
+
|
|
108
|
+
const changedEntries = comparison.entries.filter(
|
|
109
|
+
(entry) => Math.abs(entry.parsed.absoluteDiff) > 0 || Math.abs(entry.gzip.absoluteDiff) > 0,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const visibleEntries = [];
|
|
113
|
+
const hiddenEntries = [];
|
|
114
|
+
|
|
115
|
+
for (const entry of changedEntries) {
|
|
116
|
+
const { parsed, gzip } = entry;
|
|
117
|
+
const isSignificantChange =
|
|
118
|
+
Math.abs(parsed.absoluteDiff) > parsedSizeChangeThreshold ||
|
|
119
|
+
Math.abs(gzip.absoluteDiff) > gzipSizeChangeThreshold;
|
|
120
|
+
if (isSignificantChange && visibleEntries.length < visibleLimit) {
|
|
121
|
+
visibleEntries.push(entry);
|
|
122
|
+
} else {
|
|
123
|
+
hiddenEntries.push(entry);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const importantChanges = visibleEntries.map(generateEmphasizedChange);
|
|
128
|
+
const hiddenChanges = hiddenEntries.map(generateEmphasizedChange);
|
|
129
|
+
|
|
130
|
+
// Add important changes to markdown
|
|
131
|
+
if (importantChanges.length > 0) {
|
|
132
|
+
// Show the most significant changes first, up to the visible limit
|
|
133
|
+
const visibleChanges = importantChanges.slice(0, visibleLimit);
|
|
134
|
+
markdownContent += `${visibleChanges.join('\n')}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// If there are more changes, add them in a collapsible details section
|
|
138
|
+
if (hiddenChanges.length > 0) {
|
|
139
|
+
markdownContent += `\n<details>\n<summary>Show ${hiddenChanges.length} more bundle changes</summary>\n\n`;
|
|
140
|
+
markdownContent += `${hiddenChanges.join('\n')}\n\n`;
|
|
141
|
+
markdownContent += `</details>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return markdownContent;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
*
|
|
149
|
+
* @param {PrInfo} prInfo
|
|
150
|
+
* @param {string} [circleciBuildNumber] - The CircleCI build number
|
|
151
|
+
* @returns {URL}
|
|
152
|
+
*/
|
|
153
|
+
function getDetailsUrl(prInfo, circleciBuildNumber) {
|
|
154
|
+
const detailedComparisonUrl = new URL(
|
|
155
|
+
`https://frontend-public.mui.com/size-comparison/${prInfo.base.repo.full_name}/diff`,
|
|
156
|
+
);
|
|
157
|
+
detailedComparisonUrl.searchParams.set('prNumber', String(prInfo.number));
|
|
158
|
+
detailedComparisonUrl.searchParams.set('baseRef', prInfo.base.ref);
|
|
159
|
+
detailedComparisonUrl.searchParams.set('baseCommit', prInfo.base.sha);
|
|
160
|
+
detailedComparisonUrl.searchParams.set('headCommit', prInfo.head.sha);
|
|
161
|
+
if (circleciBuildNumber) {
|
|
162
|
+
detailedComparisonUrl.searchParams.set('circleCIBuildNumber', circleciBuildNumber);
|
|
163
|
+
}
|
|
164
|
+
return detailedComparisonUrl;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
*
|
|
169
|
+
* @param {PrInfo} prInfo
|
|
170
|
+
* @param {string} [circleciBuildNumber] - The CircleCI build number
|
|
171
|
+
* @returns {Promise<string>} Markdown report
|
|
172
|
+
*/
|
|
173
|
+
export async function renderMarkdownReport(prInfo, circleciBuildNumber) {
|
|
174
|
+
let markdownContent = '';
|
|
175
|
+
|
|
176
|
+
const baseCommit = prInfo.base.sha;
|
|
177
|
+
const prCommit = prInfo.head.sha;
|
|
178
|
+
const repo = prInfo.base.repo.full_name;
|
|
179
|
+
const [baseSnapshot, prSnapshot] = await Promise.all([
|
|
180
|
+
fetchSnapshot(repo, baseCommit).catch((error) => {
|
|
181
|
+
console.error(`Error fetching base snapshot: ${error}`);
|
|
182
|
+
return null;
|
|
183
|
+
}),
|
|
184
|
+
fetchSnapshot(repo, prCommit),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
if (!baseSnapshot) {
|
|
188
|
+
markdownContent += `_:no_entry_sign: No bundle size snapshot found for base commit ${baseCommit}._\n\n`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sizeDiff = calculateSizeDiff(baseSnapshot ?? {}, prSnapshot);
|
|
192
|
+
|
|
193
|
+
const report = renderMarkdownReportContent(sizeDiff);
|
|
194
|
+
|
|
195
|
+
markdownContent += report;
|
|
196
|
+
|
|
197
|
+
markdownContent += `\n\n[Details of bundle changes](${getDetailsUrl(prInfo, circleciBuildNumber)})`;
|
|
198
|
+
|
|
199
|
+
return markdownContent;
|
|
200
|
+
}
|
package/src/sizeDiff.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description Represents a single bundle size entry
|
|
3
|
+
* @typedef {Object} SizeSnapshotEntry
|
|
4
|
+
* @property {number} parsed
|
|
5
|
+
* @property {number} gzip
|
|
6
|
+
*
|
|
7
|
+
* @description Represents a single bundle size snapshot
|
|
8
|
+
* @typedef {Object.<string, SizeSnapshotEntry>} SizeSnapshot
|
|
9
|
+
*
|
|
10
|
+
* @description Represents a single bundle size comparison
|
|
11
|
+
* @typedef {Object} Size
|
|
12
|
+
* @property {string} id - Bundle identifier
|
|
13
|
+
* @property {Object} parsed - Parsed size information
|
|
14
|
+
* @property {number} parsed.previous - Previous parsed size
|
|
15
|
+
* @property {number} parsed.current - Current parsed size
|
|
16
|
+
* @property {number} parsed.absoluteDiff - Absolute difference in parsed size
|
|
17
|
+
* @property {number|null} parsed.relativeDiff - Relative difference in parsed size
|
|
18
|
+
* @property {Object} gzip - Gzipped size information
|
|
19
|
+
* @property {number} gzip.previous - Previous gzipped size
|
|
20
|
+
* @property {number} gzip.current - Current gzipped size
|
|
21
|
+
* @property {number} gzip.absoluteDiff - Absolute difference in gzipped size
|
|
22
|
+
* @property {number|null} gzip.relativeDiff - Relative difference in gzipped size
|
|
23
|
+
*
|
|
24
|
+
* @description Represents the comparison results
|
|
25
|
+
* @typedef {Object} ComparisonResult
|
|
26
|
+
* @property {Size[]} entries - Size entries for each bundle
|
|
27
|
+
* @property {Object} totals - Total size information
|
|
28
|
+
* @property {number} totals.totalParsed - Total parsed size difference
|
|
29
|
+
* @property {number} totals.totalGzip - Total gzipped size difference
|
|
30
|
+
* @property {number} totals.totalParsedPercent - Total parsed size percentage difference
|
|
31
|
+
* @property {number} totals.totalGzipPercent - Total gzipped size percentage difference
|
|
32
|
+
* @property {Object} fileCounts - File count information
|
|
33
|
+
* @property {number} fileCounts.added - Number of added files
|
|
34
|
+
* @property {number} fileCounts.removed - Number of removed files
|
|
35
|
+
* @property {number} fileCounts.changed - Number of changed files
|
|
36
|
+
* @property {number} fileCounts.total - Total number of files
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const nullSnapshot = { parsed: 0, gzip: 0 };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Calculates size difference between two snapshots
|
|
43
|
+
*
|
|
44
|
+
* @param {SizeSnapshot} baseSnapshot - Base snapshot (previous)
|
|
45
|
+
* @param {SizeSnapshot} targetSnapshot - Target snapshot (current)
|
|
46
|
+
* @returns {ComparisonResult} Comparison result with entries, totals, and file counts
|
|
47
|
+
*/
|
|
48
|
+
export function calculateSizeDiff(baseSnapshot, targetSnapshot) {
|
|
49
|
+
const bundleKeys = Object.keys({ ...baseSnapshot, ...targetSnapshot });
|
|
50
|
+
/** @type {Size[]} */
|
|
51
|
+
const results = [];
|
|
52
|
+
|
|
53
|
+
// Track totals
|
|
54
|
+
let totalParsed = 0;
|
|
55
|
+
let totalGzip = 0;
|
|
56
|
+
let totalParsedPrevious = 0;
|
|
57
|
+
let totalGzipPrevious = 0;
|
|
58
|
+
|
|
59
|
+
// Track file counts
|
|
60
|
+
let addedFiles = 0;
|
|
61
|
+
let removedFiles = 0;
|
|
62
|
+
let changedFiles = 0;
|
|
63
|
+
|
|
64
|
+
bundleKeys.forEach((bundle) => {
|
|
65
|
+
const isNewBundle = !baseSnapshot[bundle];
|
|
66
|
+
const isRemovedBundle = !targetSnapshot[bundle];
|
|
67
|
+
const currentSize = targetSnapshot[bundle] || nullSnapshot;
|
|
68
|
+
const previousSize = baseSnapshot[bundle] || nullSnapshot;
|
|
69
|
+
|
|
70
|
+
// Update file counts
|
|
71
|
+
if (isNewBundle) {
|
|
72
|
+
addedFiles += 1;
|
|
73
|
+
} else if (isRemovedBundle) {
|
|
74
|
+
removedFiles += 1;
|
|
75
|
+
} else if (
|
|
76
|
+
currentSize.parsed !== previousSize.parsed ||
|
|
77
|
+
currentSize.gzip !== previousSize.gzip
|
|
78
|
+
) {
|
|
79
|
+
changedFiles += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parsedDiff = currentSize.parsed - previousSize.parsed;
|
|
83
|
+
const gzipDiff = currentSize.gzip - previousSize.gzip;
|
|
84
|
+
|
|
85
|
+
// Calculate relative diffs with appropriate handling of new/removed bundles
|
|
86
|
+
let parsedRelativeDiff;
|
|
87
|
+
if (isNewBundle) {
|
|
88
|
+
parsedRelativeDiff = null;
|
|
89
|
+
} else if (isRemovedBundle) {
|
|
90
|
+
parsedRelativeDiff = -1;
|
|
91
|
+
} else if (previousSize.parsed) {
|
|
92
|
+
parsedRelativeDiff = currentSize.parsed / previousSize.parsed - 1;
|
|
93
|
+
} else {
|
|
94
|
+
parsedRelativeDiff = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let gzipRelativeDiff;
|
|
98
|
+
if (isNewBundle) {
|
|
99
|
+
gzipRelativeDiff = null;
|
|
100
|
+
} else if (isRemovedBundle) {
|
|
101
|
+
gzipRelativeDiff = -1;
|
|
102
|
+
} else if (previousSize.gzip) {
|
|
103
|
+
gzipRelativeDiff = currentSize.gzip / previousSize.gzip - 1;
|
|
104
|
+
} else {
|
|
105
|
+
gzipRelativeDiff = 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const entry = {
|
|
109
|
+
id: bundle,
|
|
110
|
+
parsed: {
|
|
111
|
+
previous: previousSize.parsed,
|
|
112
|
+
current: currentSize.parsed,
|
|
113
|
+
absoluteDiff: parsedDiff,
|
|
114
|
+
relativeDiff: parsedRelativeDiff,
|
|
115
|
+
},
|
|
116
|
+
gzip: {
|
|
117
|
+
previous: previousSize.gzip,
|
|
118
|
+
current: currentSize.gzip,
|
|
119
|
+
absoluteDiff: gzipDiff,
|
|
120
|
+
relativeDiff: gzipRelativeDiff,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
results.push(entry);
|
|
125
|
+
|
|
126
|
+
// Update totals
|
|
127
|
+
totalParsed += parsedDiff;
|
|
128
|
+
totalGzip += gzipDiff;
|
|
129
|
+
totalParsedPrevious += previousSize.parsed;
|
|
130
|
+
totalGzipPrevious += previousSize.gzip;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Calculate percentage changes
|
|
134
|
+
const totalParsedPercent = totalParsedPrevious > 0 ? totalParsed / totalParsedPrevious : 0;
|
|
135
|
+
const totalGzipPercent = totalGzipPrevious > 0 ? totalGzip / totalGzipPrevious : 0;
|
|
136
|
+
|
|
137
|
+
// Sort the results
|
|
138
|
+
// Custom sorting:
|
|
139
|
+
// 1. Existing bundles that increased in size (larger increases first)
|
|
140
|
+
// 2. New bundles (larger sizes first)
|
|
141
|
+
// 3. Existing bundles that decreased in size (larger decreases first)
|
|
142
|
+
// 4. Removed bundles (larger sizes first)
|
|
143
|
+
// 5. Unchanged bundles (alphabetically)
|
|
144
|
+
results.sort((entryA, entryB) => {
|
|
145
|
+
// Helper function to determine bundle category (for sorting)
|
|
146
|
+
/** @type {(entry: Size) => number} */
|
|
147
|
+
const getCategory = (entry) => {
|
|
148
|
+
if (entry.parsed.relativeDiff === null) {
|
|
149
|
+
return 2; // New bundle
|
|
150
|
+
}
|
|
151
|
+
if (entry.parsed.relativeDiff === -1) {
|
|
152
|
+
return 4; // Removed bundle
|
|
153
|
+
}
|
|
154
|
+
if (entry.parsed.relativeDiff > 0) {
|
|
155
|
+
return 1; // Increased
|
|
156
|
+
}
|
|
157
|
+
if (entry.parsed.relativeDiff < 0) {
|
|
158
|
+
return 3; // Decreased
|
|
159
|
+
}
|
|
160
|
+
return 5; // Unchanged
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Get categories for both bundles
|
|
164
|
+
const categoryA = getCategory(entryA);
|
|
165
|
+
const categoryB = getCategory(entryB);
|
|
166
|
+
|
|
167
|
+
// Sort by category first
|
|
168
|
+
if (categoryA !== categoryB) {
|
|
169
|
+
return categoryA - categoryB;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Within the same category, sort by absolute diff (largest first)
|
|
173
|
+
const diffA = Math.abs(entryA.parsed.absoluteDiff);
|
|
174
|
+
const diffB = Math.abs(entryB.parsed.absoluteDiff);
|
|
175
|
+
|
|
176
|
+
if (diffA !== diffB) {
|
|
177
|
+
return diffB - diffA;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// If diffs are the same, sort by name
|
|
181
|
+
return entryA.id.localeCompare(entryB.id);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
entries: results,
|
|
186
|
+
totals: {
|
|
187
|
+
totalParsed,
|
|
188
|
+
totalGzip,
|
|
189
|
+
totalParsedPercent,
|
|
190
|
+
totalGzipPercent,
|
|
191
|
+
},
|
|
192
|
+
fileCounts: {
|
|
193
|
+
added: addedFiles,
|
|
194
|
+
removed: removedFiles,
|
|
195
|
+
changed: changedFiles,
|
|
196
|
+
total: bundleKeys.length,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|