Skip to content

Commit

Permalink
Refactor bundle buddy to be more re-usable in fluidframework (#3207)
Browse files Browse the repository at this point in the history
Refactor our copy of bundle buddy to be reusable in fluidframework. This change is a minimal set of changes needed for us to write a wrapper around it to achieve our goals around package size reporting (in a follow up review, added to a different package). This package will be further refactored after so that bohemia can consume it in a similar way.
  • Loading branch information
heliocliu committed Sep 4, 2020
1 parent 6b2c389 commit ec1514e
Show file tree
Hide file tree
Showing 16 changed files with 740 additions and 385 deletions.
421 changes: 421 additions & 0 deletions tools/bundle-size-tools/package-lock.json

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions tools/bundle-size-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,22 @@
"repository": "microsoft/FluidFramework",
"license": "MIT",
"author": "Microsoft",
"scripts": {},
"scripts": {
"build": "tsc -b",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"assert": "^2.0.0",
"azure-devops-node-api": "^10.1.0",
"jszip": "^3.2.2",
"msgpack-lite": "^0.1.26",
"pako": "^1.0.10",
"tslib": "^1.10.0",
"yargs": "^15.3.1"
"typescript": "^3.7.4"
},
"devDependencies": {
"@types/jszip": "^3.1.7",
"@types/msgpack-lite": "^0.1.6",
"@types/node": "^12.12.6",
"@types/pako": "^1.0.1",
"@types/webpack": "^4.1.3",
"@types/yargs": "^15.0.4"
"@types/jszip": "^3.4.1",
"@types/msgpack-lite": "^0.1.7",
"@types/node": "^11.9.4",
"@types/pako": "^1.0.1"
}
}
30 changes: 14 additions & 16 deletions tools/bundle-size-tools/src/ADO/AdoArtifactFileProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
* Licensed under the MIT License.
*/

import { strict as assert } from 'assert';
import { WebApi } from 'azure-devops-node-api';
import { Constants } from './Constants';
import { decompressStatsFile, unzipStream } from '../utilities';
import * as JSZip from 'jszip';
import JSZip from 'jszip';
import { getBundleFilePathsFromFolder, BundleFileData } from './getBundleFilePathsFromFolder';
import { Stats } from 'webpack';
import { BundleBuddyConfig } from '../BundleBuddyTypes';
Expand All @@ -29,21 +29,23 @@ export function getBundlePathsFromZipObject(jsZip: JSZip): BundleFileData[] {
* @param adoConnection - A connection to the ADO api.
* @param buildNumber - The ADO build number that contains the artifact we wish to fetch
*/
export async function getZipObjectFromArtifact(adoConnection: WebApi, buildNumber: number): Promise<JSZip> {
export async function getZipObjectFromArtifact(
adoConnection: WebApi,
projectName: string,
buildNumber: number,
bundleAnalysisArtifactName: string
): Promise<JSZip> {
const buildApi = await adoConnection.getBuildApi();

const artifactStream = await buildApi.getArtifactContentZip(
Constants.projectName,
projectName,
buildNumber,
Constants.bundleAnalysisArtifactName
bundleAnalysisArtifactName
);

// We want our relative paths to be clean, so navigating JsZip into the top level folder
const result = (await unzipStream(artifactStream)).folder(Constants.bundleAnalysisArtifactName);

if (!result) {
throw new Error(`getZipObjectFromArtifact could not find the folder ${Constants.bundleAnalysisArtifactName}`);
}
const result = (await unzipStream(artifactStream)).folder(bundleAnalysisArtifactName);
assert(result, `getZipObjectFromArtifact could not find the folder ${bundleAnalysisArtifactName}`);

return result;
}
Expand All @@ -55,9 +57,7 @@ export async function getZipObjectFromArtifact(adoConnection: WebApi, buildNumbe
*/
export async function getStatsFileFromZip(jsZip: JSZip, relativePath: string): Promise<Stats.ToJsonOutput> {
const jsZipObject = jsZip.file(relativePath);
if (!jsZipObject) {
throw new Error(`getStatsFileFromZip could not find file ${relativePath}`);
}
assert(jsZipObject, `getStatsFileFromZip could not find file ${relativePath}`);

const buffer = await jsZipObject.async('nodebuffer');
return decompressStatsFile(buffer);
Expand All @@ -70,9 +70,7 @@ export async function getStatsFileFromZip(jsZip: JSZip, relativePath: string): P
*/
export async function getBundleBuddyConfigFileFromZip(jsZip: JSZip, relativePath: string): Promise<BundleBuddyConfig> {
const jsZipObject = jsZip.file(relativePath);
if (!jsZipObject) {
throw new Error(`getBundleBuddyConfigFileFromZip could not find file ${relativePath}`);
}
assert(jsZipObject, `getBundleBuddyConfigFileFromZip could not find file ${relativePath}`);

const buffer = await jsZipObject.async('nodebuffer');
return JSON.parse(buffer.toString());
Expand Down
200 changes: 200 additions & 0 deletions tools/bundle-size-tools/src/ADO/AdoSizeComparator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { WebApi } from 'azure-devops-node-api';
import JSZip from 'jszip';
import { join } from 'path';
import { getBaselineCommit, getBuilds, getPriorCommit } from '../utilities';
import { getAzureDevopsApi } from './getAzureDevopsApi';
import { BuildStatus, BuildResult } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { IADOConstants } from './Constants';
import { getZipObjectFromArtifact, getBundlePathsFromZipObject, getStatsFileFromZip } from './AdoArtifactFileProvider';
import {
getBundlePathsFromFileSystem,
getStatsFileFromFileSystem,
getBundleBuddyConfigFromFileSystem
} from './FileSystemBundleFileProvider';
import { getBuildTagForCommit } from './getBuildTagForCommit';
import { getCommentForBundleDiff, getSimpleComment } from './getCommentForBundleDiff';
import { DefaultStatsProcessors } from './DefaultStatsProcessors';
import { compareBundles } from '../compareBundles';
import { getBundleSummaries } from './getBundleSummaries';
import { getBundleBuddyConfigMap } from './getBundleBuddyConfigMap';

export class ADOSizeComparator {
constructor(
/**
* ADO constants identifying where to fetch baseline bundle info
*/
private readonly adoConstants: IADOConstants,
/**
* The ADO connection to use to fetch baseline bundle info
*/
private readonly adoConnection: WebApi,
/**
* Path to existing local bundle size reports
*/
private readonly localReportPath: string,
/**
* Optional current PR build id to use, such as to tag for
* later update when the baseline build has not completed
*/
private readonly adoBuildId: number | undefined,
/**
* Option to do fallback on commits when either there is no associated CI build or
* it does not have the needed artifacts. Fallback is not attempted for other
* issues, such as for a failed (but still present) CI build. This generator is
* only used for fallback (it should not provide the first commit to check)
*/
private readonly getFallbackCommit: ((startingCommit: string) => Generator<string>) | undefined = undefined
) {}

/**
* Naive fallback generator provided for convenience. It yields the commit directly
* prior to the previous commit.
*/
public static * naiveFallbackCommitGenerator(startingCommit: string): Generator<string> {
let currentCommit = startingCommit;
for (let i = 0; i < 5; i++) {
currentCommit = getPriorCommit(currentCommit);
yield currentCommit;
}
}

/**
* Create a size comparison message that can be posted to a PR
* @param tagWaiting - If the build should be tagged to be updated when the baseline
* build completes (if it wasn't already complete when the comparison runs)
* @returns The size comparison message
*/
public async createSizeComparisonMessage(tagWaiting: boolean): Promise<string> {
let baselineCommit: string | undefined = getBaselineCommit();
console.log(`The baseline commit for this PR is ${baselineCommit}`);

// Some circumstances may want us to try a fallback, such as when a commit does
// not trigger any CI loops. If a fallback generator is provided, use that.
let baselineZip;
const fallbackGen = this.getFallbackCommit?.(baselineCommit!);
const recentBuilds = await getBuilds(this.adoConnection, {
project: this.adoConstants.projectName,
definitions: [this.adoConstants.ciBuildDefinitionId],
maxBuildsPerDefinition: this.adoConstants.buildsToSearch ?? 20
});
while (baselineCommit !== undefined) {
let baselineBuild = recentBuilds.find((build) => build.sourceVersion === baselineCommit);

if (baselineBuild === undefined) {
baselineCommit = fallbackGen?.next().value;
console.log(`Trying backup baseline commit ${baselineCommit}`);
continue;
}

// Baseline build does not have id
if (baselineBuild.id === undefined) {
const message = `Baseline build does not have a build id`;
console.log(message);
return message;
}

// Baseline build is pending
if (baselineBuild.status !== BuildStatus.Completed) {
const message = getSimpleComment('Baseline build for this PR has not yet completed.', baselineCommit);
console.log(message);

if (tagWaiting) {
this.tagBuildAsWaiting(baselineCommit);
}

return message;
}

// Baseline build failed
if (baselineBuild.result !== BuildResult.Succeeded) {
const message = getSimpleComment(
'Baseline CI build failed, cannot generate bundle analysis at this time',
baselineCommit
);
console.log(message);
return message;
}

// Baseline build succeeded
console.log(`Found baseline build with id: ${baselineBuild.id}`);
baselineZip = await getZipObjectFromArtifact(
this.adoConnection,
this.adoConstants.projectName,
baselineBuild.id,
this.adoConstants.bundleAnalysisArtifactName).catch(() => {
return undefined;
});

// Successful baseline build does not have the needed build artifacts
if (baselineZip === undefined) {
baselineCommit = this.getFallbackCommit?.(baselineCommit).next().value;
console.log(`Trying backup baseline commit ${baselineCommit}`);
continue;
}

// Found usable baseline zip
break;
}

// Unable to find a usable baseline
if (baselineCommit === undefined || baselineZip === undefined) {
const message = `Could not find a usable baseline build with search starting at CI ${getBaselineCommit()}`;
console.log(message);
return message;
}

const message = await this.createMessageFromZip(baselineCommit, baselineZip);
console.log(message);
return message;
}

private async tagBuildAsWaiting(baselineCommit: string): Promise<void> {
if (!this.adoBuildId) {
console.log(
'No ADO build ID was provided, we will not tag this build for follow up when the baseline build completes'
);
} else {
// Tag the current build as waiting for the results of the master CI
const buildApi = await this.adoConnection.getBuildApi();
await buildApi.addBuildTag(this.adoConstants.projectName, this.adoBuildId, getBuildTagForCommit(baselineCommit));
}
}

private async createMessageFromZip(baselineCommit: string, baselineZip: JSZip): Promise<string> {
const baselineZipBundlePaths = getBundlePathsFromZipObject(baselineZip);

const prBundleFileSystemPaths = await getBundlePathsFromFileSystem(this.localReportPath);

const configFileMap = await getBundleBuddyConfigMap({
bundleFileData: prBundleFileSystemPaths,
getBundleBuddyConfig: (relativePath) =>
getBundleBuddyConfigFromFileSystem(join(this.localReportPath, relativePath))
});

const baselineSummaries = await getBundleSummaries({
bundlePaths: baselineZipBundlePaths,
getStatsFile: (relativePath) => getStatsFileFromZip(baselineZip, relativePath),
getBundleBuddyConfigFile: (bundleName) => configFileMap.get(bundleName),
statsProcessors: DefaultStatsProcessors
});

const prSummaries = await getBundleSummaries({
bundlePaths: prBundleFileSystemPaths,
getStatsFile: (relativePath) => getStatsFileFromFileSystem(join(this.localReportPath, relativePath)),
getBundleBuddyConfigFile: (bundleName) => configFileMap.get(bundleName),
statsProcessors: DefaultStatsProcessors
});

const bundleComparisons = compareBundles(baselineSummaries, prSummaries);

console.log(JSON.stringify(bundleComparisons));

const message = getCommentForBundleDiff(bundleComparisons, baselineCommit);
return message;
}
}
28 changes: 18 additions & 10 deletions tools/bundle-size-tools/src/ADO/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,33 @@
* Licensed under the MIT License.
*/

export const Constants = {
export interface IADOConstants {
// URL for the ADO org
orgUrl: '',
orgUrl: string,

// The ADO project that contains the repo
projectName: '',
projectName: string,

// The ID for the build that runs against main when PRs are merged
ciBuildDefinitionId: 0,
ciBuildDefinitionId: number,

// The ID for the build that runs to validate PRs
prBuildDefinitionId: 0,
// Used to update tagged PRs on CI build completion
// Note: Assumes CI and PR builds both run in the same org/project
prBuildDefinitionId: number | undefined,

// The name of the build artifact that contains the bundle size artifacts
bundleAnalysisArtifactName: '',
bundleAnalysisArtifactName: string,

// The guid of the repo
projectRepoGuid: '',
// Used to post/update comments in ADO
projectRepoGuid: string | undefined,

// The name of the metric that represents the size of the whole bundle
totalSizeMetricName: ''
};
// The number of most recent ADO builds to pull when searching for one associated
// with a specific commit, default 20. Pulling more builds takes longer, but may
// be useful when there are a high volume of commits/builds.
buildsToSearch: number | undefined,
}

// The name of the metric that represents the size of the whole bundle
export const totalSizeMetricName = 'Total Size';
4 changes: 2 additions & 2 deletions tools/bundle-size-tools/src/ADO/DefaultStatsProcessors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { getEntryStatsProcessor, getTotalSizeStatsProcessor, getBundleBuddyConfigProcessor } from '../statsProcessors';
import { Constants } from './Constants';
import { totalSizeMetricName } from './Constants';

/**
* The set of stats file processors we will run on bundles
Expand All @@ -14,5 +14,5 @@ export const DefaultStatsProcessors = [
metricNameProvider: (chunk) => `${chunk.name}.js <span title="Plus dependencies">ℹ</span>`
}),
getEntryStatsProcessor({ metricNameProvider: (chunkName) => `${chunkName}.js` }),
getTotalSizeStatsProcessor({ metricName: Constants.totalSizeMetricName })
getTotalSizeStatsProcessor({ metricName: totalSizeMetricName })
];
Loading

0 comments on commit ec1514e

Please sign in to comment.