diff --git a/bazel/integration/test_runner/BUILD.bazel b/bazel/integration/test_runner/BUILD.bazel index 5eeca4cc0..d19939ec2 100644 --- a/bazel/integration/test_runner/BUILD.bazel +++ b/bazel/integration/test_runner/BUILD.bazel @@ -8,6 +8,7 @@ ts_project( tsconfig = "//bazel:tsconfig", deps = [ "//bazel:node_modules/@types/node", + "//bazel:node_modules/chalk", "//bazel:node_modules/true-case-path", ], ) diff --git a/bazel/integration/test_runner/runner.mts b/bazel/integration/test_runner/runner.mts index 681ed525b..3ab11fd9a 100644 --- a/bazel/integration/test_runner/runner.mts +++ b/bazel/integration/test_runner/runner.mts @@ -30,6 +30,7 @@ import { } from './process_utils.mjs'; import {ENVIRONMENT_TMP_PLACEHOLDER} from './constants.mjs'; import {debug} from './debug.mjs'; +import {SizeTracker} from './size-tracking.mjs'; /** Error class that is used when an integration command fails. */ class IntegrationTestCommandError extends Error {} @@ -47,6 +48,7 @@ type EnvironmentConfig = Record; */ export class TestRunner { private readonly environment: EnvironmentConfig; + private readonly sizeTracker: SizeTracker; constructor( private readonly isTestDebugMode: boolean, @@ -59,6 +61,7 @@ export class TestRunner { environment: EnvironmentConfig, ) { this.environment = this._assignDefaultEnvironmentVariables(environment); + this.sizeTracker = new SizeTracker(this.testPackage); } async run() { @@ -82,6 +85,7 @@ export class TestRunner { try { await this._runTestCommands(testWorkingDir, testEnv); + await this.sizeTracker.run(testWorkingDir, testEnv); } finally { debug('Finished running integration test commands.'); diff --git a/bazel/integration/test_runner/size-tracking.mts b/bazel/integration/test_runner/size-tracking.mts new file mode 100644 index 000000000..450424825 --- /dev/null +++ b/bazel/integration/test_runner/size-tracking.mts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {runCommandInChildProcess} from './process_utils.mjs'; +import {existsSync} from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import chalk from 'chalk'; +import {debug} from './debug.mjs'; + +// Convience access to chalk colors. +const {red, green} = chalk; +/** The size discrepancy we allow in bytes. */ +const THRESHOLD_BYTES = 5000; +/** The size discrepancy as a percentage. */ +const THRESHOLD_PERCENT = 5; + +interface SizeCheckResult { + expected: number; + actual: number | undefined; + failing: boolean; + details: + | 'missing' + | { + raw: string; + percent: string; + }; +} + +export class SizeTracker { + constructor(private readonly testPackage: string) {} + + /** + * Runs the size tracking scripting. + * + * Builds the integration test application and then checks if the size of the generated files varies too + * far from our known file sizes. + */ + async run(testWorkingDir: string, commandEnv: NodeJS.ProcessEnv): Promise { + const sizeJsonFilePath = path.join(testWorkingDir, 'size.json'); + // If the integration test provides a size.json file we use it as a size tracking marker. + if (!existsSync(sizeJsonFilePath)) { + debug(`Skipping size tracking as no size.json file was found at ${sizeJsonFilePath}`); + return; + } + const success = await runCommandInChildProcess('yarn', ['build'], testWorkingDir, commandEnv); + if (!success) { + throw Error('Failed to build for size tracking.'); + } + + const sizes: {[key: string]: SizeCheckResult} = {}; + + const expectedSizes = JSON.parse(await fs.readFile(sizeJsonFilePath, 'utf-8')) as { + [key: string]: number; + }; + + for (let [filename, expectedSize] of Object.entries(expectedSizes)) { + const generedFilePath = path.join(testWorkingDir, filename); + if (!existsSync(generedFilePath)) { + sizes[filename] = { + actual: undefined, + failing: true, + expected: expectedSize, + details: 'missing', + }; + } else { + const {size: actualSize} = await fs.stat(generedFilePath); + const absoluteSizeDiff = Math.abs(actualSize - expectedSize); + const percentSizeDiff = (absoluteSizeDiff / expectedSize) * 100; + const direction = actualSize === expectedSize ? '' : actualSize > expectedSize ? '+' : '-'; + sizes[filename] = { + actual: actualSize, + expected: expectedSize, + failing: absoluteSizeDiff > THRESHOLD_BYTES || percentSizeDiff > THRESHOLD_PERCENT, + details: { + raw: `${direction}${absoluteSizeDiff.toFixed(0)}`, + percent: `${direction}${Math.round(percentSizeDiff * 1000) / 1000}`, + }, + }; + } + } + + console.info(); + console.info(Array(80).fill('=').join('')); + console.info( + `${Array(28).fill('=').join('')} SIZE TRACKING RESULTS ${Array(29).fill('=').join('')}`, + ); + console.info(Array(80).fill('=').join('')); + let failed = false; + for (let [filename, {actual, expected, failing, details}] of Object.entries(sizes)) { + failed = failed || failing; + const bullet = failing ? red('✘') : green('✔'); + console.info(` ${bullet} ${filename}`); + if (details === 'missing') { + console.info( + ` File not found in generated integration test application, either ensure the file is created or remove it from the size tracking json file.`, + ); + } else { + console.info( + ` Actual Size: ${actual} | Expected Size: ${expected} | ${details.raw} bytes (${details.percent}%)`, + ); + } + } + console.info(); + if (failed) { + const originalSizeJsonFilePath = path.join(this.testPackage, 'size.json'); + console.info( + `If this is a desired change, please update the size limits in: ${originalSizeJsonFilePath}`, + ); + process.exitCode = 1; + } else { + console.info( + `Payload size check passed. All diffs are less than ${THRESHOLD_PERCENT}% or ${THRESHOLD_BYTES} bytes.`, + ); + } + console.info(Array(80).fill('=').join('')); + console.info(); + } +} diff --git a/bazel/package.json b/bazel/package.json index 312630df6..7182f1bc7 100644 --- a/bazel/package.json +++ b/bazel/package.json @@ -9,6 +9,7 @@ "@types/wait-on": "^5.3.4", "@types/yargs": "17.0.33", "browser-sync": "3.0.4", + "chalk": "5.4.1", "piscina": "^5.0.0", "send": "1.2.0", "true-case-path": "2.2.1", diff --git a/bazel/pnpm-lock.yaml b/bazel/pnpm-lock.yaml index ab2b50353..dfffa41d5 100644 --- a/bazel/pnpm-lock.yaml +++ b/bazel/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: browser-sync: specifier: 3.0.4 version: 3.0.4 + chalk: + specifier: 5.4.1 + version: 5.4.1 piscina: specifier: ^5.0.0 version: 5.1.2 @@ -442,6 +445,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1962,6 +1969,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.4.1: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3