@mui/internal-bundle-size-checker 1.0.9-canary.2 → 1.0.9-canary.20

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/README.md CHANGED
@@ -20,7 +20,6 @@ bundle-size-checker [options]
20
20
  Options:
21
21
 
22
22
  - `--analyze`: Creates a webpack-bundle-analyzer report for each bundle
23
- - `--accurateBundles`: Displays used bundles accurately at the cost of more CPU cycles
24
23
  - `--output`, `-o`: Path to output the size snapshot JSON file
25
24
 
26
25
  ### Configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-bundle-size-checker",
3
- "version": "1.0.9-canary.2",
3
+ "version": "1.0.9-canary.20",
4
4
  "description": "Bundle size checker for MUI packages.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -21,34 +21,25 @@
21
21
  "dependencies": {
22
22
  "@aws-sdk/client-s3": "^3.515.0",
23
23
  "@aws-sdk/credential-providers": "^3.787.0",
24
- "@babel/core": "^7.27.1",
25
- "@babel/preset-react": "^7.18.6",
26
- "@babel/preset-typescript": "^7.27.1",
27
- "babel-loader": "^10.0.0",
24
+ "@octokit/auth-action": "^6.0.1",
25
+ "@octokit/rest": "^22.0.0",
28
26
  "chalk": "^5.4.1",
29
- "compression-webpack-plugin": "^10.0.0",
30
- "css-loader": "^7.1.2",
31
27
  "env-ci": "^11.1.0",
32
28
  "execa": "^7.2.0",
33
- "fast-glob": "^3.3.2",
34
- "file-loader": "^6.2.0",
29
+ "fast-glob": "^3.3.3",
30
+ "git-url-parse": "^16.1.0",
35
31
  "micromatch": "^4.0.8",
36
- "piscina": "^4.2.1",
37
- "rollup-plugin-visualizer": "^6.0.1",
38
- "terser-webpack-plugin": "^5.3.10",
32
+ "piscina": "^4.9.2",
33
+ "rollup-plugin-visualizer": "^6.0.3",
39
34
  "vite": "^6.3.5",
40
- "webpack": "^5.90.3",
41
- "webpack-bundle-analyzer": "^4.10.1",
42
35
  "yargs": "^17.7.2"
43
36
  },
44
37
  "devDependencies": {
45
38
  "@types/env-ci": "^3.1.4",
46
39
  "@types/micromatch": "^4.0.9",
47
- "@types/webpack": "^5.28.5",
48
- "@types/webpack-bundle-analyzer": "^4.7.0",
49
40
  "@types/yargs": "^17.0.33"
50
41
  },
51
- "gitSha": "8391aed9c1a47151a520e3bfea6db8c730fdadd3",
42
+ "gitSha": "16821985adbfe5dca81daf7e269e3cb1f2d5d2d2",
52
43
  "scripts": {
53
44
  "typescript": "tsc -p tsconfig.json",
54
45
  "test": "pnpm -w test --project @mui/internal-bundle-size-checker"
@@ -29,7 +29,7 @@ const rootDir = process.cwd();
29
29
  * Creates vite configuration for bundle size checking
30
30
  * @param {ObjectEntry} entry - Entry point (string or object)
31
31
  * @param {CommandLineArgs} args
32
- * @returns {Promise<{configuration: import('vite').InlineConfig, externalsArray: string[]}>}
32
+ * @returns {Promise<import('vite').InlineConfig>}
33
33
  */
34
34
  async function createViteConfig(entry, args) {
35
35
  const entryName = entry.id;
@@ -132,7 +132,7 @@ async function createViteConfig(entry, args) {
132
132
  ],
133
133
  };
134
134
 
135
- return { configuration, externalsArray };
135
+ return configuration;
136
136
  }
137
137
 
138
138
  /**
@@ -173,14 +173,24 @@ function walkDependencyTree(chunkKey, manifest, visited = new Set()) {
173
173
 
174
174
  /**
175
175
  * Process vite output to extract bundle sizes
176
- * @param {string} outDir - The output directory
176
+ * @param {import('vite').Rollup.RollupOutput['output']} output - The Vite output
177
177
  * @param {string} entryName - The entry name
178
178
  * @returns {Promise<Map<string, { parsed: number, gzip: number }>>} - Map of bundle names to size information
179
179
  */
180
- async function processBundleSizes(outDir, entryName) {
180
+ async function processBundleSizes(output, entryName) {
181
+ const chunksByFileName = new Map(output.map((chunk) => [chunk.fileName, chunk]));
182
+
181
183
  // Read the manifest file to find the generated chunks
182
- const manifestPath = path.join(outDir, '.vite/manifest.json');
183
- const manifestContent = await fs.readFile(manifestPath, 'utf8');
184
+ const manifestChunk = chunksByFileName.get('.vite/manifest.json');
185
+ if (manifestChunk?.type !== 'asset') {
186
+ throw new Error(`Manifest file not found in output for entry: ${entryName}`);
187
+ }
188
+
189
+ const manifestContent =
190
+ typeof manifestChunk.source === 'string'
191
+ ? manifestChunk.source
192
+ : new TextDecoder().decode(manifestChunk.source);
193
+
184
194
  /** @type {Manifest} */
185
195
  const manifest = JSON.parse(manifestContent);
186
196
 
@@ -197,8 +207,11 @@ async function processBundleSizes(outDir, entryName) {
197
207
  // Process each chunk in the dependency tree in parallel
198
208
  const chunkPromises = Array.from(allChunks, async (chunkKey) => {
199
209
  const chunk = manifest[chunkKey];
200
- const filePath = path.join(outDir, chunk.file);
201
- const fileContent = await fs.readFile(filePath, 'utf8');
210
+ const outputChunk = chunksByFileName.get(chunk.file);
211
+ if (outputChunk?.type !== 'chunk') {
212
+ throw new Error(`Output chunk not found for ${chunk.file}`);
213
+ }
214
+ const fileContent = outputChunk.code;
202
215
 
203
216
  // Calculate sizes
204
217
  const parsed = Buffer.byteLength(fileContent);
@@ -220,14 +233,17 @@ async function processBundleSizes(outDir, entryName) {
220
233
  * @param {CommandLineArgs} args - Command line arguments
221
234
  * @returns {Promise<Map<string, { parsed: number, gzip: number }>>}
222
235
  */
223
- export async function getViteSizes(entry, args) {
236
+ export async function getBundleSizes(entry, args) {
224
237
  // Create vite configuration
225
- const { configuration } = await createViteConfig(entry, args);
226
- const outDir = path.join(rootDir, 'build', entry.id);
238
+ const configuration = await createViteConfig(entry, args);
227
239
 
228
240
  // Run vite build
229
- await build(configuration);
241
+ const { output } = /** @type {import('vite').Rollup.RollupOutput} */ (await build(configuration));
242
+ const manifestChunk = output.find((chunk) => chunk.fileName === '.vite/manifest.json');
243
+ if (!manifestChunk) {
244
+ throw new Error(`Manifest file not found in output for entry: ${entry.id}`);
245
+ }
230
246
 
231
247
  // Process the output to get bundle sizes
232
- return processBundleSizes(outDir, entry.id);
248
+ return processBundleSizes(output, entry.id);
233
249
  }
package/src/cli.js CHANGED
@@ -8,6 +8,9 @@ import { Piscina } from 'piscina';
8
8
  import micromatch from 'micromatch';
9
9
  import { loadConfig } from './configLoader.js';
10
10
  import { uploadSnapshot } from './uploadSnapshot.js';
11
+ import { renderMarkdownReport } from './renderMarkdownReport.js';
12
+ import { octokit } from './github.js';
13
+ import { getCurrentRepoInfo } from './git.js';
11
14
 
12
15
  /**
13
16
  * @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
@@ -19,12 +22,12 @@ const DEFAULT_CONCURRENCY = os.availableParallelism();
19
22
  const rootDir = process.cwd();
20
23
 
21
24
  /**
22
- * creates size snapshot for every bundle that built with webpack
25
+ * creates size snapshot for every bundle
23
26
  * @param {CommandLineArgs} args
24
27
  * @param {NormalizedBundleSizeCheckerConfig} config - The loaded configuration
25
28
  * @returns {Promise<Array<[string, { parsed: number, gzip: number }]>>}
26
29
  */
27
- async function getWebpackSizes(args, config) {
30
+ async function getBundleSizes(args, config) {
28
31
  const worker = new Piscina({
29
32
  filename: new URL('./worker.js', import.meta.url).href,
30
33
  maxThreads: args.concurrency || DEFAULT_CONCURRENCY,
@@ -72,6 +75,42 @@ async function getWebpackSizes(args, config) {
72
75
  return sizeArrays.flat();
73
76
  }
74
77
 
78
+ /**
79
+ * Report command handler
80
+ * @param {ReportCommandArgs} argv - Command line arguments
81
+ */
82
+ async function reportCommand(argv) {
83
+ const { pr, owner: argOwner, repo: argRepo } = argv;
84
+
85
+ // Get current repo info and coerce with provided arguments
86
+ const currentRepo = await getCurrentRepoInfo();
87
+ const owner = argOwner ?? currentRepo.owner;
88
+ const repo = argRepo ?? currentRepo.repo;
89
+
90
+ if (typeof pr !== 'number') {
91
+ throw new Error('Invalid pull request number. Please provide a valid --pr option.');
92
+ }
93
+
94
+ // Validate that both owner and repo are available
95
+ if (!owner || !repo) {
96
+ throw new Error(
97
+ 'Repository owner and name are required. Please provide --owner and --repo options, or run this command from within a git repository.',
98
+ );
99
+ }
100
+
101
+ // Fetch PR information
102
+ const { data: prInfo } = await octokit.pulls.get({
103
+ owner,
104
+ repo,
105
+ pull_number: pr,
106
+ });
107
+
108
+ // Generate and print the markdown report
109
+ const report = await renderMarkdownReport(prInfo);
110
+ // eslint-disable-next-line no-console
111
+ console.log(report);
112
+ }
113
+
75
114
  /**
76
115
  * Main runner function
77
116
  * @param {CommandLineArgs} argv - Command line arguments
@@ -86,12 +125,14 @@ async function run(argv) {
86
125
  // eslint-disable-next-line no-console
87
126
  console.log(`Starting bundle size snapshot creation with ${concurrency} workers...`);
88
127
 
89
- const webpackSizes = await getWebpackSizes(argv, config);
90
- const bundleSizes = Object.fromEntries(webpackSizes.sort((a, b) => a[0].localeCompare(b[0])));
128
+ const bundleSizes = await getBundleSizes(argv, config);
129
+ const sortedBundleSizes = Object.fromEntries(
130
+ bundleSizes.sort((a, b) => a[0].localeCompare(b[0])),
131
+ );
91
132
 
92
133
  // Ensure output directory exists
93
134
  await fs.mkdir(path.dirname(snapshotDestPath), { recursive: true });
94
- await fs.writeFile(snapshotDestPath, JSON.stringify(bundleSizes, null, 2));
135
+ await fs.writeFile(snapshotDestPath, JSON.stringify(sortedBundleSizes, null, 2));
95
136
 
96
137
  // eslint-disable-next-line no-console
97
138
  console.log(`Bundle size snapshot written to ${snapshotDestPath}`);
@@ -109,56 +150,73 @@ async function run(argv) {
109
150
  // Exit with error code to indicate failure
110
151
  process.exit(1);
111
152
  }
153
+ } else {
154
+ // eslint-disable-next-line no-console
155
+ console.log('No upload configuration provided, skipping upload.');
112
156
  }
113
157
  }
114
158
 
115
159
  yargs(process.argv.slice(2))
116
- // @ts-expect-error
117
- .command({
118
- command: '$0',
119
- describe: 'Saves a size snapshot in size-snapshot.json',
120
- builder: (cmdYargs) => {
121
- return cmdYargs
122
- .option('analyze', {
123
- default: false,
124
- describe: 'Creates a webpack-bundle-analyzer report for each bundle.',
125
- type: 'boolean',
126
- })
127
- .option('accurateBundles', {
128
- default: false,
129
- describe: 'Displays used bundles accurately at the cost of more CPU cycles.',
130
- type: 'boolean',
131
- })
132
- .option('verbose', {
133
- default: false,
134
- describe: 'Show more detailed information during compilation.',
135
- type: 'boolean',
136
- })
137
- .option('vite', {
138
- default: false,
139
- describe: 'Use Vite instead of webpack for bundling.',
140
- type: 'boolean',
141
- })
142
- .option('output', {
143
- alias: 'o',
144
- describe:
145
- 'Path to output the size snapshot JSON file (defaults to size-snapshot.json in current directory).',
146
- type: 'string',
147
- })
148
- .option('filter', {
149
- alias: 'F',
150
- describe: 'Filter entry points by glob pattern(s) applied to their IDs',
151
- type: 'array',
152
- })
153
- .option('concurrency', {
154
- alias: 'c',
155
- describe: 'Number of workers to use for parallel processing',
156
- type: 'number',
157
- default: DEFAULT_CONCURRENCY,
158
- });
159
- },
160
- handler: run,
161
- })
160
+ .command(
161
+ /** @type {import('yargs').CommandModule<{}, CommandLineArgs>} */ ({
162
+ command: '$0',
163
+ describe: 'Saves a size snapshot in size-snapshot.json',
164
+ builder: (cmdYargs) => {
165
+ return cmdYargs
166
+ .option('analyze', {
167
+ default: false,
168
+ describe: 'Creates a report for each bundle.',
169
+ type: 'boolean',
170
+ })
171
+ .option('verbose', {
172
+ default: false,
173
+ describe: 'Show more detailed information during compilation.',
174
+ type: 'boolean',
175
+ })
176
+ .option('output', {
177
+ alias: 'o',
178
+ describe:
179
+ 'Path to output the size snapshot JSON file (defaults to size-snapshot.json in current directory).',
180
+ type: 'string',
181
+ })
182
+ .option('filter', {
183
+ alias: 'F',
184
+ describe: 'Filter entry points by glob pattern(s) applied to their IDs',
185
+ type: 'array',
186
+ })
187
+ .option('concurrency', {
188
+ alias: 'c',
189
+ describe: 'Number of workers to use for parallel processing',
190
+ type: 'number',
191
+ default: DEFAULT_CONCURRENCY,
192
+ });
193
+ },
194
+ handler: run,
195
+ }),
196
+ )
197
+ .command(
198
+ /** @type {import('yargs').CommandModule<{}, ReportCommandArgs>} */ ({
199
+ command: 'report',
200
+ describe: 'Generate a markdown report for a pull request',
201
+ builder: (cmdYargs) => {
202
+ return cmdYargs
203
+ .option('pr', {
204
+ describe: 'Pull request number',
205
+ type: 'number',
206
+ demandOption: true,
207
+ })
208
+ .option('owner', {
209
+ describe: 'Repository owner (defaults to current git repo owner)',
210
+ type: 'string',
211
+ })
212
+ .option('repo', {
213
+ describe: 'Repository name (defaults to current git repo name)',
214
+ type: 'string',
215
+ });
216
+ },
217
+ handler: reportCommand,
218
+ }),
219
+ )
162
220
  .help()
163
221
  .strict(true)
164
222
  .version(false)
@@ -1,8 +1,10 @@
1
+ // This file must be importable in the browser
2
+
1
3
  /**
2
4
  *
3
5
  * @param {string} repo - The name of the repository e.g. 'mui/material-ui'
4
6
  * @param {string} sha - The commit SHA
5
- * @returns {Promise<import('./sizeDiff').SizeSnapshot>} - The size snapshot data
7
+ * @returns {Promise<import('./sizeDiff.js').SizeSnapshot>} - The size snapshot data
6
8
  */
7
9
  export async function fetchSnapshot(repo, sha) {
8
10
  const urlsToTry = [
@@ -25,7 +27,7 @@ export async function fetchSnapshot(repo, sha) {
25
27
  continue;
26
28
  }
27
29
 
28
- return response.json();
30
+ return /** @type {Promise<any>} */ (response.json());
29
31
  } catch (error) {
30
32
  lastError = error;
31
33
  continue;
@@ -34,61 +36,3 @@ export async function fetchSnapshot(repo, sha) {
34
36
 
35
37
  throw new Error(`Failed to fetch snapshot`, { cause: lastError });
36
38
  }
37
-
38
- /**
39
- * Gets parent commits for a given commit SHA using GitHub API
40
- * @param {string} repo - Repository name (e.g., 'mui/material-ui')
41
- * @param {string} commit - The commit SHA to start from
42
- * @param {number} depth - How many commits to retrieve (including the starting commit)
43
- * @returns {Promise<string[]>} Array of commit SHAs in chronological order (excluding the starting commit)
44
- */
45
- async function getParentCommits(repo, commit, depth = 4) {
46
- try {
47
- const response = await fetch(
48
- `https://api.github.com/repos/${repo}/commits?sha=${commit}&per_page=${depth}`,
49
- );
50
- if (!response.ok) {
51
- throw new Error(`GitHub API request failed: ${response.status}`);
52
- }
53
-
54
- /** @type {{ sha: string }[]} */
55
- const commits = await response.json();
56
- // Skip the first commit (which is the starting commit) and return the rest
57
- return commits.slice(1).map((commitDetails) => commitDetails.sha);
58
- } catch (/** @type {any} */ error) {
59
- console.warn(`Failed to get parent commits for ${commit}: ${error.message}`);
60
- return [];
61
- }
62
- }
63
-
64
- /**
65
- * Attempts to fetch a snapshot with fallback to parent commits
66
- * @param {string} repo - Repository name
67
- * @param {string} commit - The commit SHA to start from
68
- * @param {number} [fallbackDepth=3] - How many parent commits to try as fallback
69
- * @returns {Promise<{snapshot: import('./sizeDiff').SizeSnapshot | null, actualCommit: string | null}>}
70
- */
71
- export async function fetchSnapshotWithFallback(repo, commit, fallbackDepth = 3) {
72
- // Try the original commit first
73
- try {
74
- const snapshot = await fetchSnapshot(repo, commit);
75
- return { snapshot, actualCommit: commit };
76
- } catch (/** @type {any} */ error) {
77
- // fallthrough to parent commits if the snapshot for the original commit fails
78
- }
79
-
80
- // Get parent commits and try each one
81
- const parentCommits = await getParentCommits(repo, commit, fallbackDepth + 1);
82
-
83
- for (const parentCommit of parentCommits) {
84
- try {
85
- // eslint-disable-next-line no-await-in-loop
86
- const snapshot = await fetchSnapshot(repo, parentCommit);
87
- return { snapshot, actualCommit: parentCommit };
88
- } catch {
89
- // fallthrough to the next parent commit if fetching fails
90
- }
91
- }
92
-
93
- return { snapshot: null, actualCommit: null };
94
- }
@@ -0,0 +1,34 @@
1
+ import { fetchSnapshot } from './fetchSnapshot.js';
2
+ import { getParentCommits } from './git.js';
3
+
4
+ /**
5
+ * Attempts to fetch a snapshot with fallback to parent commits
6
+ * @param {string} repo - Repository name
7
+ * @param {string} commit - The commit SHA to start from
8
+ * @param {number} [fallbackDepth=3] - How many parent commits to try as fallback
9
+ * @returns {Promise<{snapshot: import('./sizeDiff.js').SizeSnapshot | null, actualCommit: string | null}>}
10
+ */
11
+ export async function fetchSnapshotWithFallback(repo, commit, fallbackDepth = 3) {
12
+ // Try the original commit first
13
+ try {
14
+ const snapshot = await fetchSnapshot(repo, commit);
15
+ return { snapshot, actualCommit: commit };
16
+ } catch (/** @type {any} */ error) {
17
+ // fallthrough to parent commits if the snapshot for the original commit fails
18
+ }
19
+
20
+ // Get parent commits and try each one
21
+ const parentCommits = await getParentCommits(repo, commit, fallbackDepth);
22
+
23
+ for (const parentCommit of parentCommits) {
24
+ try {
25
+ // eslint-disable-next-line no-await-in-loop
26
+ const snapshot = await fetchSnapshot(repo, parentCommit);
27
+ return { snapshot, actualCommit: parentCommit };
28
+ } catch {
29
+ // fallthrough to the next parent commit if fetching fails
30
+ }
31
+ }
32
+
33
+ return { snapshot: null, actualCommit: null };
34
+ }
package/src/git.js ADDED
@@ -0,0 +1,45 @@
1
+ import { execa } from 'execa';
2
+ import gitUrlParse from 'git-url-parse';
3
+
4
+ /**
5
+ * Gets parent commits for a given commit SHA using git CLI
6
+ * @param {string} repo - Repository name (e.g., 'mui/material-ui') - ignored for git CLI
7
+ * @param {string} commit - The commit SHA to start from
8
+ * @param {number} depth - How many commits to retrieve (including the starting commit)
9
+ * @returns {Promise<string[]>} Array of commit SHAs in chronological order (excluding the starting commit)
10
+ */
11
+ export async function getParentCommits(repo, commit, depth = 3) {
12
+ const { stdout } = await execa('git', ['rev-list', `--max-count=${depth}`, '--skip=1', commit]);
13
+ return stdout.trim().split('\n').filter(Boolean);
14
+ }
15
+
16
+ /**
17
+ * Compares two commits and returns merge base information using git CLI
18
+ * @param {string} base - Base commit SHA
19
+ * @param {string} head - Head commit SHA
20
+ * @returns {Promise<string>} Object with merge base commit info
21
+ */
22
+ export async function getMergeBase(base, head) {
23
+ const { stdout } = await execa('git', ['merge-base', base, head]);
24
+ return stdout.trim();
25
+ }
26
+
27
+ /**
28
+ * Gets the current repository owner and name from git remote
29
+ * @returns {Promise<{owner: string | null, repo: string | null}>}
30
+ */
31
+ export async function getCurrentRepoInfo() {
32
+ try {
33
+ const { stdout } = await execa('git', ['remote', 'get-url', 'origin']);
34
+ const parsed = gitUrlParse(stdout.trim());
35
+ return {
36
+ owner: parsed.owner,
37
+ repo: parsed.name,
38
+ };
39
+ } catch (error) {
40
+ return {
41
+ owner: null,
42
+ repo: null,
43
+ };
44
+ }
45
+ }
package/src/github.js ADDED
@@ -0,0 +1,11 @@
1
+ // @ts-check
2
+
3
+ import { Octokit } from '@octokit/rest';
4
+ import { createActionAuth } from '@octokit/auth-action';
5
+
6
+ // Create and export Octokit instance
7
+ /** @type {import('@octokit/rest').Octokit} */
8
+ export const octokit = new Octokit({
9
+ authStrategy: process.env.GITHUB_TOKEN ? createActionAuth : undefined,
10
+ userAgent: 'bundle-size-checker',
11
+ });
package/src/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ /// <reference types="./types.d.ts" />
2
+
1
3
  import defineConfig from './defineConfig.js';
2
4
  import { loadConfig } from './configLoader.js';
3
5
  import { calculateSizeDiff } from './sizeDiff.js';
@@ -5,8 +5,11 @@
5
5
  */
6
6
 
7
7
  import { calculateSizeDiff } from './sizeDiff.js';
8
- import { fetchSnapshot, fetchSnapshotWithFallback } from './fetchSnapshot.js';
8
+ import { fetchSnapshot } from './fetchSnapshot.js';
9
9
  import { displayPercentFormatter, byteSizeChangeFormatter } from './formatUtils.js';
10
+ import { octokit } from './github.js';
11
+ import { getCurrentRepoInfo, getMergeBase } from './git.js';
12
+ import { fetchSnapshotWithFallback } from './fetchSnapshotWithFallback.js';
10
13
 
11
14
  /**
12
15
  * Generates a symbol based on the relative change value.
@@ -96,11 +99,13 @@ function formatMarkdownTable(columns, data) {
96
99
  const separators = alignments.map((align) => {
97
100
  switch (align) {
98
101
  case 'center':
99
- return ':----------:';
102
+ return ':---------:';
100
103
  case 'right':
101
104
  return '----------:';
105
+ case 'left':
106
+ return ':----------';
102
107
  default:
103
- return '----------';
108
+ return '-----------';
104
109
  }
105
110
  });
106
111
  table += `|${separators.join('|')}|\n`;
@@ -140,9 +145,9 @@ export function renderMarkdownReportContent(
140
145
 
141
146
  markdownContent += formatMarkdownTable(
142
147
  [
143
- { field: 'id', header: 'Bundle' },
144
- { field: 'parsed', header: 'Parsed Size', align: 'right' },
145
- { field: 'gzip', header: 'Gzip Size', align: 'right' },
148
+ { field: 'id', header: 'Bundle', align: 'left' },
149
+ { field: 'parsed', header: 'Parsed size', align: 'right' },
150
+ { field: 'gzip', header: 'Gzip size', align: 'right' },
146
151
  ],
147
152
  trackedEntries.map(({ id, parsed, gzip }) => ({
148
153
  id,
@@ -224,11 +229,27 @@ function getDetailsUrl(prInfo, options = {}) {
224
229
  export async function renderMarkdownReport(prInfo, circleciBuildNumber, options = {}) {
225
230
  let markdownContent = '';
226
231
 
227
- const baseCommit = prInfo.base.sha;
228
232
  const prCommit = prInfo.head.sha;
229
233
  const repo = prInfo.base.repo.full_name;
230
234
  const { fallbackDepth = 3 } = options;
231
235
 
236
+ const [owner, repoName] = repo.split('/');
237
+
238
+ const currentRepo = await getCurrentRepoInfo();
239
+
240
+ let baseCommit;
241
+ if (owner === currentRepo.owner && repoName === currentRepo.repo) {
242
+ baseCommit = await getMergeBase(prInfo.base.sha, prCommit);
243
+ } else {
244
+ const { data } = await octokit.repos.compareCommits({
245
+ owner,
246
+ repo: repoName,
247
+ base: prInfo.base.sha,
248
+ head: prCommit,
249
+ });
250
+ baseCommit = data.merge_base_commit.sha;
251
+ }
252
+
232
253
  const [baseResult, prSnapshot] = await Promise.all([
233
254
  fetchSnapshotWithFallback(repo, baseCommit, fallbackDepth),
234
255
  fetchSnapshot(repo, prCommit),
@@ -237,9 +258,9 @@ export async function renderMarkdownReport(prInfo, circleciBuildNumber, options
237
258
  const { snapshot: baseSnapshot, actualCommit: actualBaseCommit } = baseResult;
238
259
 
239
260
  if (!baseSnapshot) {
240
- markdownContent += `_:no_entry_sign: No bundle size snapshot found for base commit ${baseCommit} or any of its ${fallbackDepth} parent commits._\n\n`;
261
+ markdownContent += `_:no_entry_sign: No bundle size snapshot found for merge base ${baseCommit} or any of its ${fallbackDepth} parent commits._\n\n`;
241
262
  } else if (actualBaseCommit !== baseCommit) {
242
- markdownContent += `_:information_source: Using snapshot from parent commit ${actualBaseCommit} (fallback from ${baseCommit})._\n\n`;
263
+ markdownContent += `_:information_source: Using snapshot from parent commit ${actualBaseCommit} (fallback from merge base ${baseCommit})._\n\n`;
243
264
  }
244
265
 
245
266
  const sizeDiff = calculateSizeDiff(baseSnapshot ?? {}, prSnapshot);
@@ -2,13 +2,30 @@
2
2
  import { vi, describe, it, expect, beforeEach } from 'vitest';
3
3
  import { renderMarkdownReport } from './renderMarkdownReport.js';
4
4
  import * as fetchSnapshotModule from './fetchSnapshot.js';
5
+ import * as fetchSnapshotWithFallbackModule from './fetchSnapshotWithFallback.js';
5
6
 
6
7
  // Mock the fetchSnapshot module
7
8
  vi.mock('./fetchSnapshot.js');
9
+ // Mock the fetchSnapshotWithFallback module
10
+ vi.mock('./fetchSnapshotWithFallback.js');
11
+ // Mock the @octokit/rest module
12
+ vi.mock('@octokit/rest', () => ({
13
+ Octokit: vi.fn(() => ({
14
+ repos: {
15
+ compareCommits: vi.fn(),
16
+ listCommits: vi.fn(),
17
+ },
18
+ pulls: {
19
+ get: vi.fn(),
20
+ },
21
+ })),
22
+ }));
8
23
 
9
24
  describe('renderMarkdownReport', () => {
10
25
  const mockFetchSnapshot = vi.mocked(fetchSnapshotModule.fetchSnapshot);
11
- const mockFetchSnapshotWithFallback = vi.mocked(fetchSnapshotModule.fetchSnapshotWithFallback);
26
+ const mockFetchSnapshotWithFallback = vi.mocked(
27
+ fetchSnapshotWithFallbackModule.fetchSnapshotWithFallback,
28
+ );
12
29
 
13
30
  /** @type {PrInfo} */
14
31
  const mockPrInfo = {
@@ -24,9 +41,26 @@ describe('renderMarkdownReport', () => {
24
41
  },
25
42
  };
26
43
 
27
- beforeEach(() => {
44
+ beforeEach(async () => {
28
45
  mockFetchSnapshot.mockClear();
29
46
  mockFetchSnapshotWithFallback.mockClear();
47
+
48
+ // Import and mock the octokit instance after mocking the module
49
+ const { octokit } = await import('./github.js');
50
+
51
+ // Set up default mock for compareCommits to return the base commit SHA
52
+ vi.mocked(octokit.repos.compareCommits).mockResolvedValue(
53
+ /** @type {any} */ ({
54
+ data: {
55
+ merge_base_commit: {
56
+ sha: mockPrInfo.base.sha,
57
+ },
58
+ },
59
+ }),
60
+ );
61
+
62
+ // Clear any previous mock calls
63
+ vi.mocked(octokit.repos.compareCommits).mockClear();
30
64
  });
31
65
 
32
66
  it('should generate markdown report with size increases', async () => {
@@ -109,7 +143,7 @@ describe('renderMarkdownReport', () => {
109
143
  const result = await renderMarkdownReport(mockPrInfo);
110
144
 
111
145
  expect(result).toContain(
112
- 'No bundle size snapshot found for base commit abc123 or any of its 3 parent commits.',
146
+ 'No bundle size snapshot found for merge base abc123 or any of its 3 parent commits.',
113
147
  );
114
148
  });
115
149
 
@@ -324,8 +358,8 @@ describe('renderMarkdownReport', () => {
324
358
  });
325
359
 
326
360
  expect(result).toMatchInlineSnapshot(`
327
- "| Bundle | Parsed Size | Gzip Size |
328
- |----------|----------:|----------:|
361
+ "| Bundle | Parsed size | Gzip size |
362
+ |:----------|----------:|----------:|
329
363
  | @mui/material/Button/index.js | 🔺+400B<sup>(+2.67%)</sup> | 🔺+100B<sup>(+2.22%)</sup> |
330
364
  | @mui/material/TextField/index.js | 🔺+200B<sup>(+0.91%)</sup> | 🔺+100B<sup>(+1.54%)</sup> |
331
365
 
@@ -359,8 +393,8 @@ describe('renderMarkdownReport', () => {
359
393
  });
360
394
 
361
395
  expect(result).toMatchInlineSnapshot(`
362
- "| Bundle | Parsed Size | Gzip Size |
363
- |----------|----------:|----------:|
396
+ "| Bundle | Parsed size | Gzip size |
397
+ |:----------|----------:|----------:|
364
398
  | @mui/material/Button/index.js | 🔺+500B<sup>(+3.33%)</sup> | 🔺+150B<sup>(+3.33%)</sup> |
365
399
  | @mui/material/TextField/index.js | 🔺+300B<sup>(+1.36%)</sup> | 🔺+150B<sup>(+2.31%)</sup> |
366
400
 
@@ -394,8 +428,8 @@ describe('renderMarkdownReport', () => {
394
428
  });
395
429
 
396
430
  expect(result).toMatchInlineSnapshot(`
397
- "| Bundle | Parsed Size | Gzip Size |
398
- |----------|----------:|----------:|
431
+ "| Bundle | Parsed size | Gzip size |
432
+ |:----------|----------:|----------:|
399
433
  | @mui/material/Button/index.js | 🔺+400B<sup>(+2.67%)</sup> | 🔺+100B<sup>(+2.22%)</sup> |
400
434
 
401
435
 
@@ -428,8 +462,8 @@ describe('renderMarkdownReport', () => {
428
462
  });
429
463
 
430
464
  expect(result).toMatchInlineSnapshot(`
431
- "| Bundle | Parsed Size | Gzip Size |
432
- |----------|----------:|----------:|
465
+ "| Bundle | Parsed size | Gzip size |
466
+ |:----------|----------:|----------:|
433
467
  | @mui/material/Button/index.js | 0B<sup>(0.00%)</sup> | 0B<sup>(0.00%)</sup> |
434
468
  | @mui/material/TextField/index.js | 0B<sup>(0.00%)</sup> | 0B<sup>(0.00%)</sup> |
435
469
 
@@ -461,8 +495,8 @@ describe('renderMarkdownReport', () => {
461
495
  });
462
496
 
463
497
  expect(result).toMatchInlineSnapshot(`
464
- "| Bundle | Parsed Size | Gzip Size |
465
- |----------|----------:|----------:|
498
+ "| Bundle | Parsed size | Gzip size |
499
+ |:----------|----------:|----------:|
466
500
  | @mui/material/Button/index.js | 🔺+400B<sup>(+2.67%)</sup> | 🔺+100B<sup>(+2.22%)</sup> |
467
501
 
468
502
 
@@ -512,7 +546,9 @@ describe('renderMarkdownReport', () => {
512
546
 
513
547
  const result = await renderMarkdownReport(mockPrInfo);
514
548
 
515
- expect(result).toContain('Using snapshot from parent commit parent1 (fallback from abc123)');
549
+ expect(result).toContain(
550
+ 'Using snapshot from parent commit parent1 (fallback from merge base abc123)',
551
+ );
516
552
  expect(result).toContain('baseCommit=parent1');
517
553
  });
518
554
 
@@ -527,7 +563,7 @@ describe('renderMarkdownReport', () => {
527
563
  const result = await renderMarkdownReport(mockPrInfo);
528
564
 
529
565
  expect(result).toContain(
530
- 'No bundle size snapshot found for base commit abc123 or any of its 3 parent commits.',
566
+ 'No bundle size snapshot found for merge base abc123 or any of its 3 parent commits.',
531
567
  );
532
568
  });
533
569
 
@@ -548,7 +584,9 @@ describe('renderMarkdownReport', () => {
548
584
 
549
585
  const result = await renderMarkdownReport(mockPrInfo, undefined, { fallbackDepth: 1 });
550
586
 
551
- expect(result).toContain('Using snapshot from parent commit parent1 (fallback from abc123)');
587
+ expect(result).toContain(
588
+ 'Using snapshot from parent commit parent1 (fallback from merge base abc123)',
589
+ );
552
590
  expect(mockFetchSnapshotWithFallback).toHaveBeenCalledWith('mui/material-ui', 'abc123', 1);
553
591
  });
554
592
  });
package/src/types.d.ts CHANGED
@@ -1,23 +1,3 @@
1
- // WebpackEntry type
2
- interface WebpackEntry {
3
- import: string;
4
- importName?: string;
5
- }
6
-
7
- // Webpack stats types
8
- interface StatsAsset {
9
- name: string;
10
- size: number;
11
- related?: {
12
- find: (predicate: (asset: any) => boolean) => { size: number; type: string };
13
- };
14
- }
15
-
16
- interface StatsChunkGroup {
17
- name: string;
18
- assets: Array<{ name: string; size: number }>;
19
- }
20
-
21
1
  // Upload configuration with optional properties
22
2
  interface UploadConfig {
23
3
  repo?: string; // The repository name (e.g., "mui/material-ui")
@@ -65,12 +45,16 @@ interface NormalizedBundleSizeCheckerConfig {
65
45
  // Command line argument types
66
46
  interface CommandLineArgs {
67
47
  analyze?: boolean;
68
- accurateBundles?: boolean;
69
48
  output?: string;
70
49
  verbose?: boolean;
71
50
  filter?: string[];
72
51
  concurrency?: number;
73
- vite?: boolean;
52
+ }
53
+
54
+ interface ReportCommandArgs {
55
+ pr?: number;
56
+ owner?: string;
57
+ repo?: string;
74
58
  }
75
59
 
76
60
  // Diff command argument types
@@ -20,7 +20,7 @@ async function getCurrentCommitSHA() {
20
20
  */
21
21
  function sanitizeS3TagString(str) {
22
22
  // Replace disallowed characters with underscore
23
- const safe = str.replace(/[^a-zA-Z0-9 +-=.:/@]+/g, '_');
23
+ const safe = str.replace(/[^a-zA-Z0-9 +\-=.:/@]+/g, '_');
24
24
  // Truncate to max lengths (256 for value)
25
25
  const maxLen = 256;
26
26
  return safe.length > maxLen ? safe.substring(0, maxLen) : safe;
package/src/worker.js CHANGED
@@ -4,8 +4,7 @@ import fs from 'fs/promises';
4
4
  import chalk from 'chalk';
5
5
  import * as module from 'module';
6
6
  import { byteSizeFormatter } from './formatUtils.js';
7
- import { getWebpackSizes } from './webpackBuilder.js';
8
- import { getViteSizes } from './viteBuilder.js';
7
+ import { getBundleSizes } from './builder.js';
9
8
 
10
9
  const require = module.createRequire(import.meta.url);
11
10
 
@@ -83,12 +82,7 @@ export default async function getSizes({ entry, args, index, total }) {
83
82
  }
84
83
 
85
84
  try {
86
- let sizeMap;
87
- if (args.vite) {
88
- sizeMap = await getViteSizes(entry, args);
89
- } else {
90
- sizeMap = await getWebpackSizes(entry, args);
91
- }
85
+ const sizeMap = await getBundleSizes(entry, args);
92
86
 
93
87
  // Create a concise log message showing import details
94
88
  let entryDetails = '';
@@ -112,7 +106,6 @@ export default async function getSizes({ entry, args, index, total }) {
112
106
  ${chalk.green('✓')} ${chalk.green.bold(`Completed ${index + 1}/${total}: [${entry.id}]`)}
113
107
  ${chalk.cyan('Import:')} ${entryDetails}
114
108
  ${chalk.cyan('Externals:')} ${entry.externals.join(', ')}
115
- ${chalk.cyan('Bundler:')} ${args.vite ? 'vite' : 'webpack'}
116
109
  ${chalk.cyan('Sizes:')} ${chalk.yellow(byteSizeFormatter.format(entrySize.parsed))} (${chalk.yellow(byteSizeFormatter.format(entrySize.gzip))} gzipped)
117
110
  ${args.analyze ? ` ${chalk.cyan('Analysis:')} ${chalk.underline(pathToFileURL(path.join(rootDir, 'build', `${entry.id}.html`)).href)}` : ''}
118
111
  `.trim(),
package/tsconfig.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "node",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
6
  "allowJs": true,
7
7
  "checkJs": true,
8
8
  "skipLibCheck": true,
@@ -1,267 +0,0 @@
1
- import { promisify } from 'util';
2
- import path from 'path';
3
- import webpackCallbackBased from 'webpack';
4
- import CompressionPlugin from 'compression-webpack-plugin';
5
- import TerserPlugin from 'terser-webpack-plugin';
6
- import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
7
- import { createRequire } from 'node:module';
8
-
9
- /**
10
- * @type {(options: webpackCallbackBased.Configuration) => Promise<webpackCallbackBased.Stats>}
11
- */
12
- // @ts-expect-error Can't select the right overload
13
- const webpack = promisify(webpackCallbackBased);
14
- const rootDir = process.cwd();
15
- const require = createRequire(import.meta.url);
16
-
17
- /**
18
- * Creates webpack configuration for bundle size checking
19
- * @param {ObjectEntry} entry - Entry point (string or object)
20
- * @param {CommandLineArgs} args
21
- * @returns {Promise<{configuration: import('webpack').Configuration, externalsArray: string[]}>}
22
- */
23
- async function createWebpackConfig(entry, args) {
24
- const analyzerMode = args.analyze ? 'static' : 'disabled';
25
- const concatenateModules = !args.accurateBundles;
26
-
27
- const entryName = entry.id;
28
- let entryContent;
29
-
30
- if (entry.code && (entry.import || entry.importedNames)) {
31
- entryContent = entry.code;
32
- } else if (entry.code) {
33
- entryContent = entry.code;
34
- } else if (entry.import) {
35
- if (entry.importedNames && entry.importedNames.length > 0) {
36
- // Generate named imports for each name in the importedNames array
37
- const imports = entry.importedNames
38
- .map((name) => `import { ${name} } from '${entry.import}';`)
39
- .join('\n');
40
- const logs = entry.importedNames.map((name) => `console.log(${name});`).join('\n');
41
- entryContent = `${imports}\n${logs}`;
42
- } else {
43
- // Default to import * as if importedNames is not defined
44
- entryContent = `import * as _ from '${entry.import}';\nconsole.log(_);`;
45
- }
46
- } else {
47
- throw new Error(`Entry "${entry.id}" must have either code or import property defined`);
48
- }
49
-
50
- /**
51
- * Generate externals function from an array of package names
52
- * @param {string[]} packages - Array of package names to exclude (defaults to react and react-dom)
53
- * @returns {function} - Function to determine if a request should be treated as external
54
- */
55
- function createExternalsFunction(packages = ['react', 'react-dom']) {
56
- /**
57
- * Check if a request should be treated as external
58
- * Uses the new recommended format to avoid deprecation warnings
59
- * @param {{ context: string, request: string }} params - Object containing context and request
60
- * @param {Function} callback - Callback to handle the result
61
- */
62
- return ({ request }, callback) => {
63
- // Iterate through all packages and check if request is equal to or starts with package + '/'
64
- for (const pkg of packages) {
65
- if (request === pkg || request.startsWith(`${pkg}/`)) {
66
- return callback(null, `commonjs ${request}`);
67
- }
68
- }
69
-
70
- return callback();
71
- };
72
- }
73
-
74
- // Use externals from the entry object
75
- const externalsArray = entry.externals || ['react', 'react-dom'];
76
-
77
- /**
78
- * @type {import('webpack').Configuration}
79
- */
80
- const configuration = {
81
- externals: [
82
- // @ts-expect-error -- webpack types are not compatible with the current version
83
- createExternalsFunction(externalsArray),
84
- ],
85
- mode: 'production',
86
- optimization: {
87
- concatenateModules,
88
- minimizer: [
89
- new TerserPlugin({
90
- test: /\.m?js(\?.*)?$/i,
91
- // Avoid creating LICENSE.txt files for each module
92
- // See https://github.com/webpack-contrib/terser-webpack-plugin#remove-comments
93
- terserOptions: {
94
- format: {
95
- comments: false,
96
- },
97
- },
98
- extractComments: false,
99
- }),
100
- ],
101
- },
102
- module: {
103
- rules: [
104
- {
105
- test: /\.[jt]sx?$/,
106
- include: rootDir,
107
- exclude: /node_modules/,
108
- use: {
109
- loader: require.resolve('babel-loader'),
110
- options: {
111
- presets: [
112
- require.resolve('@babel/preset-react'),
113
- require.resolve('@babel/preset-typescript'),
114
- ],
115
- },
116
- },
117
- },
118
- {
119
- test: /\.css$/,
120
- use: [require.resolve('css-loader')],
121
- },
122
- {
123
- test: /\.(png|svg|jpg|gif)$/,
124
- use: [require.resolve('file-loader')],
125
- },
126
- ],
127
- },
128
- output: {
129
- filename: '[name].js',
130
- library: {
131
- // TODO: Use `type: 'module'` once it is supported (currently incompatible with `externals`)
132
- name: 'M',
133
- type: 'var',
134
- // type: 'module',
135
- },
136
- path: path.join(rootDir, 'build'),
137
- },
138
- plugins: [
139
- new CompressionPlugin({
140
- filename: '[path][base][fragment].gz',
141
- }),
142
- new BundleAnalyzerPlugin({
143
- analyzerMode,
144
- // We create a report for each bundle so around 120 reports.
145
- // Opening them all is spam.
146
- // If opened with `webpack --config . --analyze` it'll still open one new tab though.
147
- openAnalyzer: false,
148
- // '[name].html' not supported: https://github.com/webpack-contrib/webpack-bundle-analyzer/issues/12
149
- reportFilename: `${entryName}.html`,
150
- logLevel: 'warn',
151
- }),
152
- ],
153
- // A context to the current dir, which has a node_modules folder with workspace dependencies
154
- context: rootDir,
155
- entry: {
156
- // This format is a data: url combined with inline matchResource to obtain a virtual entry.
157
- // See https://github.com/webpack/webpack/issues/6437#issuecomment-874466638
158
- // See https://webpack.js.org/api/module-methods/#import
159
- // See https://webpack.js.org/api/loaders/#inline-matchresource
160
- [entryName]: `./index.js!=!data:text/javascript;charset=utf-8;base64,${Buffer.from(entryContent.trim()).toString('base64')}`,
161
- },
162
- // TODO: 'browserslist:modern'
163
- // See https://github.com/webpack/webpack/issues/14203
164
- target: 'web',
165
- };
166
-
167
- // Return both the configuration and the externals array
168
- return { configuration, externalsArray };
169
- }
170
-
171
- /**
172
- * Process webpack stats to extract bundle sizes
173
- * @param {import('webpack').Stats} webpackStats - The webpack stats object
174
- * @returns {Map<string, { parsed: number, gzip: number }>} - Map of bundle names to size information
175
- */
176
- function processBundleSizes(webpackStats) {
177
- /** @type {Map<string, { parsed: number, gzip: number }>} */
178
- const sizeMap = new Map();
179
-
180
- if (!webpackStats) {
181
- throw new Error('No webpack stats were returned');
182
- }
183
-
184
- if (webpackStats.hasErrors()) {
185
- const statsJson = webpackStats.toJson({
186
- all: false,
187
- entrypoints: true,
188
- errors: true,
189
- });
190
-
191
- const entrypointKeys = statsJson.entrypoints ? Object.keys(statsJson.entrypoints) : [];
192
-
193
- throw new Error(
194
- `ERROR: The following errors occurred during bundling of ${entrypointKeys.join(', ')} with webpack: \n${(
195
- statsJson.errors || []
196
- )
197
- .map((error) => {
198
- return `${JSON.stringify(error, null, 2)}`;
199
- })
200
- .join('\n')}`,
201
- );
202
- }
203
-
204
- const stats = webpackStats.toJson({
205
- all: false,
206
- assets: true,
207
- entrypoints: true,
208
- relatedAssets: true,
209
- });
210
-
211
- if (!stats.assets) {
212
- return sizeMap;
213
- }
214
-
215
- const assets = new Map(stats.assets.map((asset) => [asset.name, asset]));
216
-
217
- if (stats.entrypoints) {
218
- Object.values(stats.entrypoints).forEach((entrypoint) => {
219
- let parsedSize = 0;
220
- let gzipSize = 0;
221
-
222
- if (entrypoint.assets) {
223
- entrypoint.assets.forEach(({ name, size }) => {
224
- const asset = assets.get(name);
225
- if (asset && asset.related) {
226
- const gzippedAsset = asset.related.find((relatedAsset) => {
227
- return relatedAsset.type === 'gzipped';
228
- });
229
-
230
- if (size !== undefined) {
231
- parsedSize += size;
232
- }
233
-
234
- if (gzippedAsset && gzippedAsset.size !== undefined) {
235
- gzipSize += gzippedAsset.size;
236
- }
237
- }
238
- });
239
- }
240
-
241
- if (!entrypoint.name) {
242
- throw new Error('Entrypoint name is undefined');
243
- }
244
-
245
- sizeMap.set(entrypoint.name, { parsed: parsedSize, gzip: gzipSize });
246
- });
247
- }
248
-
249
- return sizeMap;
250
- }
251
-
252
- /**
253
- * Get sizes for a webpack bundle
254
- * @param {ObjectEntry} entry - The entry configuration
255
- * @param {CommandLineArgs} args - Command line arguments
256
- * @returns {Promise<Map<string, { parsed: number, gzip: number }>>}
257
- */
258
- export async function getWebpackSizes(entry, args) {
259
- // Create webpack configuration
260
- const { configuration } = await createWebpackConfig(entry, args);
261
-
262
- // Run webpack
263
- const webpackStats = await webpack(configuration);
264
-
265
- // Process the webpack stats to get bundle sizes
266
- return processBundleSizes(webpackStats);
267
- }