From 269a3c93b91d8f4c18209ef6ca0ace3f1d24aa9a Mon Sep 17 00:00:00 2001 From: Tommy Brosman Date: Thu, 23 Apr 2026 13:37:17 -0700 Subject: [PATCH 01/67] Initial version of bundle comparison script. Note: includes changes to the pnpm-lock and pnpm-workspace that need to be reverted before this change is reviewed or checked in. --- .../utils/bundle-size-tests/eslint.config.mts | 20 + examples/utils/bundle-size-tests/package.json | 7 +- .../scripts/compare-bundles.ts | 542 +++++++++++ .../bundle-size-tests/tsconfig.scripts.json | 9 + pnpm-lock.yaml | 894 +++++++++++++++--- pnpm-workspace.yaml | 4 +- 6 files changed, 1356 insertions(+), 120 deletions(-) create mode 100644 examples/utils/bundle-size-tests/scripts/compare-bundles.ts create mode 100644 examples/utils/bundle-size-tests/tsconfig.scripts.json diff --git a/examples/utils/bundle-size-tests/eslint.config.mts b/examples/utils/bundle-size-tests/eslint.config.mts index 7a6d122ed2c2..01724bbc5dc4 100644 --- a/examples/utils/bundle-size-tests/eslint.config.mts +++ b/examples/utils/bundle-size-tests/eslint.config.mts @@ -8,6 +8,26 @@ import { recommended } from "@fluidframework/eslint-config-fluid/flat.mts"; const config: Linter.Config[] = [ ...recommended, + { + // Include script files in typed linting via an explicit tsconfig list. + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + languageOptions: { + parserOptions: { + projectService: false, + project: ["./tsconfig.json", "./tsconfig.scripts.json", "./src/test/tsconfig.json"], + }, + }, + }, + { + files: ["scripts/**/*.ts"], + rules: { + "import-x/no-nodejs-modules": "off", + "unicorn/filename-case": "off", + "@typescript-eslint/prefer-nullish-coalescing": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + }, { rules: { "@typescript-eslint/consistent-type-imports": "off", diff --git a/examples/utils/bundle-size-tests/package.json b/examples/utils/bundle-size-tests/package.json index 589d048f5141..685bb1b1226c 100644 --- a/examples/utils/bundle-size-tests/package.json +++ b/examples/utils/bundle-size-tests/package.json @@ -21,8 +21,9 @@ "check:biome": "biome check .", "check:format": "npm run check:biome", "clean": "rimraf --glob build dist lib bundleAnalysis \"**/*.tsbuildinfo\" \"**/*.build.log\" nyc", - "eslint": "eslint --quiet --format stylish src", - "eslint:fix": "eslint --quiet --format stylish src --fix --fix-type problem,suggestion,layout", + "compare:bundles": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/compare-bundles.ts", + "eslint": "eslint --quiet --format stylish src scripts", + "eslint:fix": "eslint --quiet --format stylish src scripts --fix --fix-type problem,suggestion,layout", "explore:tree": "fluid-build . --task webpack && source-map-explorer ./build/sharedTree.js --html bundleAnalysis/reportTree.html", "format": "npm run format:biome", "format:biome": "biome check . --write", @@ -60,6 +61,7 @@ "@fluidframework/bundle-size-tools": "catalog:buildTools", "@fluidframework/eslint-config-fluid": "catalog:eslint", "@mixer/webpack-bundle-compare": "^0.1.0", + "@msgpack/msgpack": "^2.8.0", "@types/mocha": "^10.0.10", "@types/node": "catalog:types", "eslint": "catalog:eslint", @@ -71,6 +73,7 @@ "source-map-loader": "^5.0.0", "string-replace-loader": "^3.1.0", "ts-loader": "^9.5.1", + "tsx": "^4.19.4", "typescript": "~5.4.5", "webpack": "^5.94.0", "webpack-bundle-analyzer": "^4.5.0", diff --git a/examples/utils/bundle-size-tests/scripts/compare-bundles.ts b/examples/utils/bundle-size-tests/scripts/compare-bundles.ts new file mode 100644 index 000000000000..0f59a584506d --- /dev/null +++ b/examples/utils/bundle-size-tests/scripts/compare-bundles.ts @@ -0,0 +1,542 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gunzipSync, gzipSync } from "node:zlib"; + +import { decode } from "@msgpack/msgpack"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const defaultAnalysisDirectory = resolve(scriptDirectory, ".."); + +/** + * Extracts the value of a command-line option from the argument list. + * Supports both "--option value" and "--option=value" formats. + * + * @param argv - The command-line argument list + * @param optionName - The name of the option to extract (e.g., "--base-branch") + * @returns The option value, or undefined if not found + */ +function getOptionValue(argv: string[], optionName: string): string | undefined { + const optionPrefix = `${optionName}=`; + const index = argv.findIndex((arg) => arg === optionName || arg.startsWith(optionPrefix)); + if (index === -1) { + return undefined; + } + + const optionArg = argv[index]; + if (optionArg === undefined) { + return undefined; + } + + if (optionArg.startsWith(optionPrefix)) { + return optionArg.slice(optionPrefix.length); + } + + return argv[index + 1]; +} + +/** + * Checks if a flag is present in the command-line argument list. + * + * @param argv - The command-line argument list + * @param flagName - The flag to check for (e.g., "--help") + * @returns True if the flag is present, false otherwise + */ +function hasFlag(argv: string[], flagName: string): boolean { + return argv.includes(flagName); +} + +/** + * Sanitizes a string for use as a filename by replacing non-alphanumeric characters with underscores. + * + * @param value - The string to sanitize + * @returns The sanitized string safe for use as a filename + */ +function sanitizeForFileName(value: string): string { + return value.replace(/[^\w.-]/g, "_"); +} + +/** Represents a single asset in a bundle. */ +interface AssetStat { + /** The name of the asset (e.g., "bundle.js") */ + name: string; + /** The parsed size of the asset in bytes */ + size?: number; +} + +/** Represents aggregated statistics for a bundle build. */ +interface BundleStats { + /** Array of assets and their sizes */ + assets?: AssetStat[]; + /** Entrypoint names mapped to their constituent assets and sizes */ + entrypoints?: Record; +} + +/** + * Generates candidate file paths for bundle stats files in order of preference. + * Tries multiple possible locations to handle different directory structures. + * + * @param analysisDirectory - The base analysis directory containing bundle stats + * @param label - The label suffix used to identify the specific stats (e.g., "parent", "current") + * @returns An array of candidate file paths to check in order + */ +function getStatsFileCandidates(analysisDirectory: string, label: string): string[] { + return [ + resolve(analysisDirectory, `bundleAnalysis.${label}`, "bundleStats.msp.gz"), + resolve(analysisDirectory, "bundleAnalysis", label, "bundleStats.msp.gz"), + resolve(analysisDirectory, "bundleAnalysis", "bundleStats.msp.gz"), + ]; +} + +/** + * Loads and deserializes bundle statistics from a MessagePack-compressed file. + * Attempts multiple file locations and throws an error if stats cannot be found. + * + * @param analysisDirectory - The base analysis directory + * @param label - The label suffix used to identify the specific stats + * @returns Parsed bundle statistics + * @throws Error if bundle stats file cannot be found in any candidate location + */ +function loadStats(analysisDirectory: string, label: string): BundleStats { + const candidates = getStatsFileCandidates(analysisDirectory, label); + const statsFilePath = candidates.find((candidate) => existsSync(candidate)); + if (statsFilePath === undefined) { + const expectedPaths = candidates.map((candidate) => ` - ${candidate}`).join("\n"); + throw new Error( + [ + `Could not find bundle stats for label "${label}".`, + "Checked:", + expectedPaths, + "", + "If your base stats are stored in a different location, pass --base-label or --analysis-dir explicitly.", + ].join("\n"), + ); + } + + const compressedData = readFileSync(statsFilePath); + const decompressedData = gunzipSync(compressedData); + return decode(decompressedData) as BundleStats; +} + +/** + * Calculates the gzip-compressed size of a file using maximum compression level. + * + * @param filePath - The path to the file + * @returns The gzip-compressed size in bytes, or undefined if the file cannot be read + */ +function gzipSize(filePath: string): number | undefined { + try { + return gzipSync(readFileSync(filePath), { level: 9 }).length; + } catch { + return undefined; + } +} + +/** Reporter interface for dual console and text file output. */ +interface Reporter { + /** Prints a line to both console and internal buffer */ + print: (line?: string) => void; + /** Prints a section header with spacing */ + section: (title: string) => void; + /** Prints a table header with a divider line */ + tableHeader: (header: string, dividerLength: number) => void; + /** Returns all accumulated output as a single text string */ + toText: () => string; +} + +/** + * Creates a reporter that outputs to both console and a text buffer. + * Allows for simultaneous console output and file writing of the same content. + * + * @returns A Reporter instance with print, section, tableHeader, and toText methods + */ +function createReporter(): Reporter { + const outputLines: string[] = []; + + function print(line = ""): void { + console.log(line); + outputLines.push(line); + } + + function section(title: string): void { + print(); + print(title); + } + + function tableHeader(header: string, dividerLength: number): void { + print(header); + print("-".repeat(dividerLength)); + } + + function toText(): string { + return `${outputLines.join("\n")}\n`; + } + + return { print, section, tableHeader, toText }; +} + +/** Represents a comparison row for a single asset between two builds. */ +interface CompareRow { + /** The asset name */ + name: string; + /** The parsed size in the base build */ + baseStatSize: number; + /** The parsed size in the current build */ + currentStatSize: number; +} + +/** + * Formats an asset comparison row for tabular display. + * Includes name, base size, current size, diff, and percentage change. + * + * @param row - The comparison row to format + * @returns A formatted string suitable for console output + */ +function formatAssetRow(row: CompareRow): string { + const diff = row.currentStatSize - row.baseStatSize; + const percentChange = + row.baseStatSize > 0 && diff !== 0 + ? `${((diff / row.baseStatSize) * 100).toFixed(1)}%` + : ""; + const diffStr = diff === 0 ? "-" : `${diff > 0 ? "+" : ""}${diff}`; + + return ( + (row.name + (diff === 0 ? "" : " *")).padEnd(40) + + String(row.baseStatSize).padStart(12) + + String(row.currentStatSize).padStart(12) + + diffStr.padStart(12) + + percentChange.padStart(10) + ); +} + +/** + * Formats an entrypoint comparison row for tabular display. + * Includes entrypoint name, base size, current size, and diff. + * + * @param entrypointName - The name of the entrypoint + * @param baseSize - The total parsed size of assets in the base entrypoint + * @param currentSize - The total parsed size of assets in the current entrypoint + * @returns A formatted string suitable for console output + */ +function formatEntrypointRow( + entrypointName: string, + baseSize: number, + currentSize: number, +): string { + const diff = currentSize - baseSize; + const diffStr = diff === 0 ? "-" : `${diff > 0 ? "+" : ""}${diff}`; + return ( + entrypointName.padEnd(30) + + String(baseSize).padStart(12) + + String(currentSize).padStart(12) + + diffStr.padStart(12) + ); +} + +/** Parsed command-line options for the bundle comparison script. */ +interface Options { + /** Branch name for the base build (default: "main") */ + baseBranch: string; + /** Branch name for the current build (default: current branch or BUILD_SOURCEBRANCHNAME) */ + currentBranch: string; + /** Label suffix for base stats files (default: "parent") */ + baseLabel: string; + /** Label suffix for current stats files (default: "current") */ + currentLabel: string; + /** Directory containing bundleAnalysis folders (default: examples/utils/bundle-size-tests) */ + analysisDirectory: string; + /** Directory where comparison output files are written (default: analysis-dir/bundleAnalysis) */ + outputDirectory: string; + /** Build output directory for base gzip size comparison (default: analysis-dir/bundleAnalysis.base-label/build) */ + baseBuildDirectory: string; + /** Build output directory for current gzip size comparison (default: analysis-dir/build) */ + currentBuildDirectory: string; +} + +/** + * Parses command-line arguments into an Options object. + * Provides defaults for all options and reads BUILD_SOURCEBRANCHNAME environment variable. + * + * @param argv - The command-line argument list + * @returns Parsed options with defaults applied + */ +function parseOptions(argv: string[]): Options { + const baseBranch = getOptionValue(argv, "--base-branch") ?? "main"; + const currentBranch = + getOptionValue(argv, "--current-branch") ?? + process.env.BUILD_SOURCEBRANCHNAME ?? + "current"; + const baseLabel = getOptionValue(argv, "--base-label") ?? "parent"; + const currentLabel = getOptionValue(argv, "--current-label") ?? "current"; + const analysisDirectory = resolve( + getOptionValue(argv, "--analysis-dir") ?? defaultAnalysisDirectory, + ); + const outputDirectory = resolve( + getOptionValue(argv, "--output-dir") ?? `${analysisDirectory}/bundleAnalysis`, + ); + const baseBuildDirectory = resolve( + getOptionValue(argv, "--base-build-dir") ?? + `${analysisDirectory}/bundleAnalysis.${baseLabel}/build`, + ); + const currentBuildDirectory = resolve( + getOptionValue(argv, "--current-build-dir") ?? `${analysisDirectory}/build`, + ); + + return { + baseBranch, + currentBranch, + baseLabel, + currentLabel, + analysisDirectory, + outputDirectory, + baseBuildDirectory, + currentBuildDirectory, + }; +} + +/** + * Writes comparison results to both text and JSON output files. + * Creates the output directory if it does not exist. + * File names are derived from sanitized branch names (e.g., "compare-main-to-dev.txt"). + * + * @param outputDirectory - The directory where output files will be written + * @param baseBranch - The base branch name (used in output filename) + * @param currentBranch - The current branch name (used in output filename) + * @param textContent - The formatted text comparison report + * @param jsonObject - The structured comparison data as a JSON-serializable object + */ +function writeOutputFiles( + outputDirectory: string, + baseBranch: string, + currentBranch: string, + textContent: string, + jsonObject: object, +): void { + mkdirSync(outputDirectory, { recursive: true }); + + const baseRevision = sanitizeForFileName(baseBranch); + const currentRevision = sanitizeForFileName(currentBranch); + const outputBaseName = `compare-${baseRevision}-to-${currentRevision}`; + const textOutputPath = resolve(outputDirectory, `${outputBaseName}.txt`); + const jsonOutputPath = resolve(outputDirectory, `${outputBaseName}.json`); + + writeFileSync(textOutputPath, textContent); + writeFileSync(jsonOutputPath, `${JSON.stringify(jsonObject, undefined, 2)}\n`); + + console.log("\nWrote comparison outputs:"); + console.log(` ${textOutputPath}`); + console.log(` ${jsonOutputPath}`); +} + +/** + * Prints the help text describing usage, options, and examples for the script. + */ +function printHelp(): void { + console.log(` +Usage: + tsx ./scripts/compare-bundles.ts [options] + +Options: + --help, -h + Show this help text and exit. + + --base-branch Base branch label in output (default: main) + --current-branch Current branch label in output (default: BUILD_SOURCEBRANCHNAME or current) + --base-label Base stats directory suffix (default: parent) + --current-label Current stats directory suffix (default: current) + + --analysis-dir Directory containing bundleAnalysis.