Repository: paularmstrong/build-tracker
Pull request #93 (2019-09-03 23:40:02)

[server] return budget results in the insert response
<!--
Thank you so much for contributing to open source and the Build Tracker project!
-->

# Problem

When inserting with the API or CLI, it's difficult to scrape out usable information about budgets failing or passing.

# Solution

In the response from the API insert method, add two items, `groupDeltas` and `artifactDeltas`, extracted from the `buildDelta` between the build and its parent.

# TODO

- [x] 🤓 Add & update tests (always try to _increase_ test coverage)
- [x] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [x] 📖 Update relevant documentation



Patch:
diff --git a/docs/docs/cli.md b/docs/docs/cli.md
index 7e7ab6c..bfe4450 100644
--- a/docs/docs/cli.md
+++ b/docs/docs/cli.md
@@ -34,9 +34,7 @@ Once one of these is found and parsed, the search will stop and that object will
 
 The configuration search can also be short-circuited by passing a `--config` argument with a path to your configuration file.
 
-### Options
-
-#### `applicationUrl: string`
+### `applicationUrl: string`
 
 The full URL (with scheme) to your application. New builds will be posted to the API available at this URL
 
@@ -48,7 +46,7 @@ module.exports = {
 };
 ```
 
-#### `artifacts: Array<string>`
+### `artifacts: Array<string>`
 
 An array of [glob](https://github.com/isaacs/node-glob#readme) paths to search for your artifacts.
 
@@ -58,7 +56,7 @@ module.exports = {
 };
 ```
 
-#### `baseDir?: string = process.cwd()`
+### `baseDir?: string = process.cwd()`
 
 A base directory option to calculate relative paths for your artifacts. This is most often useful for stripping off unwanted paths, like `dist`, though it must be an absolute path on your system. This can be most easily accomplished by using the `path` node API:
 
@@ -68,7 +66,7 @@ module.exports = {
 };
 ```
 
-#### `cwd?: string = process.cwd()`
+### `cwd?: string = process.cwd()`
 
 The command working directory. Set this to change the script working path to something else other than `process.cwd()`
 
@@ -78,7 +76,7 @@ module.exports = {
 };
 ```
 
-#### `getFilenameHash?: (filename: string) => string | void`
+### `getFilenameHash?: (filename: string) => string | void`
 
 This optional method allows you to extract a filename hash from the built artifact filename. Many applications built with webpack will include a chunk hash in the filename for cache-busting reasons.
 
@@ -102,7 +100,7 @@ module.exports = {
 };
 ```
 
-#### `nameMapper?: (filename: string) => string`
+### `nameMapper?: (filename: string) => string`
 
 Similar to the `getFilenameHash` method above, you may want to strip out parts of the artifact filenames to make them more legible in reports and the application.
 
@@ -121,7 +119,7 @@ module.exports = {
 };
 ```
 
-#### `buildUrlFormat?: string`
+### `buildUrlFormat?: string`
 
 You may want to link each build to a commit. Provide `buildUrlFormat` in the following format to map revision value to an url.
 
@@ -131,7 +129,7 @@ module.exports = {
 };
 ```
 
-#### `onCompare?: (data: APIResponse) => Promise<void>`
+### `onCompare?: (data: APIResponse) => Promise<void>`
 
 Take any action on the response from the API.
 
@@ -145,6 +143,46 @@ module.exports = {
 };
 ```
 
+The data response consists of a lot of useful data. Depending on how you want to report information, you may only need part of it.
+
+```ts
+interface APIResponse {
+  build: { meta: BuildMeta; artifacts: Array<Artifact> };
+  parentBuild: { meta: BuildMeta; artifacts: Array<Artifact> };
+  groupDeltas: Array<ArtifactDelta>;
+  artifactDeltas: Array<ArtifactDelta>;
+  json: ComparisonMatrix; // Object representation of the build comparison chart
+  markdown: string; // Markdown table of the build comparison chart
+  csv: string; // CSV formatted string of the build comparison chart
+}
+```
+
+Most likely, you'll care about `groupDeltas` and `artifactDeltas`. These contain information about every group and artifact in your build, compared to its parent.
+
+```ts
+interface ArtifactDelta {
+  name: string;
+  sizes: { [key: string]: number };
+  percents: { [key: string]: number };
+  hashChanged: boolean;
+  budgets: Array<BudgetResult>;
+  failingBudgets: Array<BudgetResult>;
+}
+```
+
+The `failingBudgets` on each `ArtifactDelta` will be an array of budgets that you have configured that have been exceeded in this change.
+
+```ts
+interface BudgetResult {
+  sizeKey: string;
+  passing: boolean;
+  expected: number;
+  actual: number;
+  type: Budget['type'];
+  level: Budget['level'];
+}
+```
+
 ## Commands
 
 ### `upload-build`
diff --git a/docs/docs/guides/ci.md b/docs/docs/guides/ci.md
index 1fb8b5d..46735c1 100644
--- a/docs/docs/guides/ci.md
+++ b/docs/docs/guides/ci.md
@@ -5,6 +5,56 @@ title: Continuous Integration
 
 The [Build Tracker CLI](../ci) is a handy tool to make uploading new builds from your CI environment simple.
 
+# Uploading a new build
+
+Using the CLI from `@build-tracker/cli`, once you've set up your [configuration file](../cli#configuration) you can upload a new build with a single command:
+
+```shell
+yarn bt-cli upload-build
+```
+
+## Reporting budget results
+
+The response from the Build Tracker API is sent back to the [`onCompare`](../cli#oncompare-data-apiresponse-promise-void) callback function in your configuration and has many useful pieces of information.
+
+There are two items that are particularly useful here: `groupDeltas` and `artifactDeltas`. We can use these and filter on those with failing budgets to format a nice message.
+
+```js
+module.exports = {
+  // other config options
+  onCompare: result => {
+    const failingGroupDeltas = result.groupDeltas.filter(delta => delta.failingBudgets.length > 0);
+    const failingArtifactDeltas = result.artifactDeltas.filter(delta => delta.failingBudgets.length > 0);
+
+    if (failingGroupBudgets.length === 0 && failingArtifactDeltas.length === 0) {
+      process.stdout.write('✅ All clear!');
+      return;
+    }
+
+    failingGroupDeltas.map(delta => {
+      const { warn, error } = delta.failingBudgets.reduce(
+        (memo, budget) => {
+          memo[budget.level] = budget;
+          return memo;
+        },
+        { warn: [], error: [] }
+      );
+      if (error.length) {
+        process.stdout.write(`🚫 ${delta.name} failed the following budgets:`);
+        error.forEach(
+          budget =>
+            `* expected ${budget.type} to be less than ${budget.expected} but received${delta.sizes[budget.sizeKey]}`
+        );
+      }
+      if (warn.length) {
+        process.stdout.write(`⚠️ ${delta.name} failed the following budgets:`);
+      }
+      // etc
+    });
+  }
+};
+```
+
 ## Linking directly to a build comparison
 
 The Build Tracker web application accept links directly to a comparison of one or more builds in the format: `/builds/:revisions+`.
diff --git a/src/server/src/api/insert.ts b/src/server/src/api/insert.ts
index a5bbeff..29c660a 100644
--- a/src/server/src/api/insert.ts
+++ b/src/server/src/api/insert.ts
@@ -25,7 +25,8 @@ export const insertBuild = (
           comparator: new Comparator({
             artifactBudgets: artifactConfig.budgets,
             artifactFilters: artifactConfig.filters,
-            builds: [build, parentBuild]
+            builds: [build, parentBuild],
+            groups: artifactConfig.groups
           }),
           parentBuild
         };
@@ -34,12 +35,15 @@ export const insertBuild = (
         return onInserted(context.comparator).then(() => context);
       })
       .then(({ comparator, parentBuild }) => {
+        const buildDelta = comparator.buildDeltas[1][0];
         res.send({
           build: build.toJSON(),
           parentBuild: parentBuild.toJSON(),
           json: comparator.toJSON(),
           markdown: comparator.toMarkdown(),
-          csv: comparator.toCsv()
+          csv: comparator.toCsv(),
+          groupDeltas: Array.from(buildDelta.groupDeltas.values()).map(delta => delta.toObject()),
+          artifactDeltas: buildDelta.artifactDeltas.map(delta => delta.toObject())
         });
       })
       .catch(error => {


Test patch:
diff --git a/src/server/src/api/__tests__/insert.test.ts b/src/server/src/api/__tests__/insert.test.ts
index 6417a1c..e8944b6 100644
--- a/src/server/src/api/__tests__/insert.test.ts
+++ b/src/server/src/api/__tests__/insert.test.ts
@@ -7,9 +7,10 @@ import Comparator from '@build-tracker/comparator';
 import express from 'express';
 import { insertBuild } from '../insert';
 import request from 'supertest';
+import { BudgetLevel, BudgetType } from '@build-tracker/types';
 
 const build = new Build({ branch: 'master', revision: 'abc', parentRevision: 'def', timestamp: Date.now() }, [
-  { hash: '123', name: 'tacos', sizes: { stat: 123 } }
+  { hash: '123', name: 'tacos', sizes: { stat: 231 } }
 ]);
 const parentBuild = new Build({ branch: 'master', revision: 'def', parentRevision: '123', timestamp: Date.now() }, [
   { hash: '123', name: 'tacos', sizes: { stat: 123 } }
@@ -83,6 +84,76 @@ describe('insert build handler', () => {
           });
         });
     });
+
+    test('includes groupDeltas', () => {
+      const handler = insertBuild(queries, {
+        ...config,
+        artifacts: {
+          groups: [
+            {
+              name: 'food',
+              artifactMatch: /tacos/,
+              budgets: [{ type: BudgetType.DELTA, level: BudgetLevel.WARN, maximum: 100 }]
+            }
+          ]
+        }
+      });
+      app.post('/test', handler);
+
+      return request(app)
+        .post('/test')
+        .send({ meta: build.meta, artifacts: build.artifacts })
+        .set('Content-Type', 'application/json')
+        .set('Accept', 'application/json')
+        .then(res => {
+          expect(res.body.groupDeltas).toEqual([
+            {
+              budgets: [],
+              failingBudgets: [],
+              hashChanged: false,
+              name: 'All',
+              percents: { stat: -0.4675324675324675 },
+              sizes: { stat: -108 }
+            },
+            {
+              budgets: [{ expected: 100, level: 'warn', passing: false, type: 'delta' }],
+              failingBudgets: [{ expected: 100, level: 'warn', passing: false, type: 'delta' }],
+              hashChanged: false,
+              name: 'food',
+              percents: { stat: -0.4675324675324675 },
+              sizes: { stat: -108 }
+            }
+          ]);
+        });
+    });
+
+    test('includes artifactDeltas', () => {
+      const handler = insertBuild(queries, {
+        ...config,
+        artifacts: {
+          budgets: { tacos: [{ type: BudgetType.DELTA, level: BudgetLevel.WARN, maximum: 100 }] }
+        }
+      });
+      app.post('/test', handler);
+
+      return request(app)
+        .post('/test')
+        .send({ meta: build.meta, artifacts: build.artifacts })
+        .set('Content-Type', 'application/json')
+        .set('Accept', 'application/json')
+        .then(res => {
+          expect(res.body.artifactDeltas).toEqual([
+            {
+              budgets: [{ expected: 100, level: 'warn', passing: false, type: 'delta' }],
+              failingBudgets: [{ expected: 100, level: 'warn', passing: false, type: 'delta' }],
+              hashChanged: false,
+              name: 'tacos',
+              percents: { stat: -0.4675324675324675 },
+              sizes: { stat: -108 }
+            }
+          ]);
+        });
+    });
   });
 
   describe('onInserted', () => {