Repository: paularmstrong/build-tracker
Pull request #197 (2020-04-18 16:33:26)

feat(comparator): add getters for errors, warnings, hashchanges
<!--
Thank you so much for contributing to open source and the Build Tracker project!
-->

# Problem

It's difficult for integrations using `@build-tracker/comparator` to quickly see if a comparison instance has errors, warnings, or unexpected hash changes without looping through all of the matrix rows and internal bits.

# Solution

Create 3 getters, `errors`, `warnings`, `unexpectedHashChanges` that can do that logic for me and return an array of each item type.

# TODO

- [ ] 🤓 Add & update tests (always try to _increase_ test coverage)
- [ ] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [ ] 📖 Update relevant documentation


github-actions[bot]: <!-- sentinel:build-tracker-action -->
⚠️: *Group "Web App"* failed the gzip budget size limit of 150 KiB by 3.69 KiB

<details><summary>View table</summary>

|         |    0e0c4aa |    3d34cde |                 Δ1 |
| :------ | ---------: | ---------: | -----------------: |
| All     | 476.48 KiB |  477.2 KiB |    0.72 KiB (0.2%) |
| Web App | 153.48 KiB | 153.69 KiB | ⚠️ 0.21 KiB (0.1%) |

</details>

View on [<img src="https://buildtracker.dev/img/favicon.png" alt="" width="20" height="20" /> Build Tracker](https://build-tracker-demo.herokuapp.com/builds/0e0c4aa76f1a55e16deaeb185525cf87676254a8/3d34cde5e07761356565cefeda64b68b1a41e9f7?comparedRevisions=0e0c4aa76f1a55e16deaeb185525cf87676254a8&comparedRevisions=3d34cde5e07761356565cefeda64b68b1a41e9f7)

Patch:
diff --git a/src/comparator/src/index.ts b/src/comparator/src/index.ts
index 8e952e2..3da2973 100644
--- a/src/comparator/src/index.ts
+++ b/src/comparator/src/index.ts
@@ -142,6 +142,26 @@ const deserializeRegExp = (input: string): RegExp => {
   return new RegExp(m[1], m[2] || '');
 };
 
+const getFailingBudgetsFromRow = (
+  rows: Array<ArtifactRow | GroupRow>,
+  level?: BudgetLevel
+): Array<{ name: string; result: BudgetResult }> => {
+  return rows.reduce((failures, row) => {
+    row.forEach((cell) => {
+      if (cell.type !== CellType.TOTAL_DELTA && cell.type !== CellType.DELTA) {
+        return;
+      }
+      cell.failingBudgets.forEach((budget) => {
+        if (level && budget.level !== level) {
+          return;
+        }
+        failures.push({ name: row[0].text, result: budget });
+      });
+    });
+    return failures;
+  }, []);
+};
+
 interface ComparatorOptions {
   artifactBudgets?: ArtifactBudgets;
   artifactFilters?: ArtifactFilters;
@@ -166,6 +186,10 @@ export default class BuildComparator {
   private _matrixGroups: ComparisonMatrix['groups'];
   private _matrixArtifacts: ComparisonMatrix['artifacts'];
 
+  private _errors: Array<{ name: string; result: BudgetResult }>;
+  private _warnings: Array<{ name: string; result: BudgetResult }>;
+  private _unexpectedHashChanges: Array<{ name: string }>;
+
   public constructor({ artifactBudgets, artifactFilters, budgets, builds, groups }: ComparatorOptions) {
     this.builds = builds
       .sort((buildA, buildB) => {
@@ -327,6 +351,43 @@ export default class BuildComparator {
     return this._matrixArtifacts;
   }
 
+  public get errors(): Array<{ name: string; result: BudgetResult }> {
+    if (!this._errors) {
+      const groupResults = getFailingBudgetsFromRow(this.matrixGroups, BudgetLevel.ERROR);
+      const artifactResults = getFailingBudgetsFromRow(this.matrixArtifacts, BudgetLevel.ERROR);
+
+      this._errors = [...groupResults, ...artifactResults].filter(Boolean);
+    }
+    return this._errors;
+  }
+
+  public get warnings(): Array<{ name: string; result: BudgetResult }> {
+    if (!this._warnings) {
+      const groupResults = getFailingBudgetsFromRow(this.matrixGroups, BudgetLevel.WARN);
+      const artifactResults = getFailingBudgetsFromRow(this.matrixArtifacts, BudgetLevel.WARN);
+
+      this._warnings = [...groupResults, ...artifactResults].filter(Boolean);
+    }
+    return this._warnings;
+  }
+
+  public get unexpectedHashChanges(): Array<{ name: string }> {
+    if (!this._unexpectedHashChanges) {
+      this._unexpectedHashChanges = this.matrixArtifacts.reduce((memo, row) => {
+        row.forEach((cell) => {
+          if (cell.type !== CellType.DELTA) {
+            return;
+          }
+          if (cell.hashChangeUnexpected) {
+            memo.push({ name: cell.name });
+          }
+        });
+        return memo;
+      }, []);
+    }
+    return this._unexpectedHashChanges;
+  }
+
   private _getArtifactRow(artifactName: string): ArtifactRow {
     const cells = this.buildDeltas.map(
       (buildDeltas, i): Array<TextCell | TotalCell | DeltaCell> => {
@@ -475,35 +536,16 @@ export default class BuildComparator {
   }
 
   public toSummary(useEmoji = true): Array<string> {
-    const groupResults = this.matrixGroups.reduce((memo, row): Array<string> => {
-      row.forEach((cell) => {
-        if (cell.type !== CellType.TOTAL_DELTA) {
-          return;
-        }
-        cell.failingBudgets.forEach((budget) => {
-          memo.push(formatBudgetResult(budget, `Group "${row[0].text}"`, useEmoji));
-        });
-      });
-      return memo;
-    }, []);
-
-    const artifactResults = this.matrixArtifacts.reduce((memo, row): Array<string> => {
-      row.forEach((cell) => {
-        if (cell.type !== CellType.DELTA) {
-          return;
-        }
-        cell.failingBudgets.forEach((budget) => {
-          memo.push(formatBudgetResult(budget, row[0].text, useEmoji));
-        });
-
-        if (cell.hashChangeUnexpected) {
-          memo.push(formatUnexpectedHashChange(row[0].text, useEmoji));
-        }
-      });
-      return memo;
-    }, []);
-
-    const output = [...groupResults, ...artifactResults].filter(Boolean);
+    const groupResults = getFailingBudgetsFromRow(this.matrixGroups).map(({ name, result }) => {
+      return formatBudgetResult(result, `Group "${name}"`, useEmoji);
+    });
+    const artifactResults = getFailingBudgetsFromRow(this.matrixArtifacts).map(({ name, result }) => {
+      return formatBudgetResult(result, name, useEmoji);
+    });
+    const unexpectedHashChanges = this.unexpectedHashChanges.map(({ name }) => {
+      return formatUnexpectedHashChange(name, useEmoji);
+    });
+    const output = [...groupResults, ...artifactResults, ...unexpectedHashChanges].filter(Boolean);
     if (output.length === 0) {
       return [`${useEmoji ? '✅' : 'Success:'} No failing budgets`];
     }


Test patch:
diff --git a/src/comparator/src/__tests__/index.test.ts b/src/comparator/src/__tests__/index.test.ts
index 024339f..95f536f 100644
--- a/src/comparator/src/__tests__/index.test.ts
+++ b/src/comparator/src/__tests__/index.test.ts
@@ -625,9 +625,9 @@ tacos,123,123,-0.04"
         },
       });
       expect(comparator.toSummary().join('\n')).toMatchInlineSnapshot(`
-"#️⃣: *burritos* hash changed without any file size change
-⚠️: *tacos* failed the gzip budget size limit of 0 KiB by 0.04 KiB
-🚨: *tacos* failed the gzip budget size limit of 0.03 KiB by 0.01 KiB"
+"⚠️: *tacos* failed the gzip budget size limit of 0 KiB by 0.04 KiB
+🚨: *tacos* failed the gzip budget size limit of 0.03 KiB by 0.01 KiB
+#️⃣: *burritos* hash changed without any file size change"
 `);
     });
 
@@ -680,4 +680,194 @@ Error: *tacos* failed the gzip budget size limit of 0.03 KiB by 0.01 KiB"
       expect(comparator.toSummary(false).join('\n')).toMatchInlineSnapshot(`"Success: No failing budgets"`);
     });
   });
+
+  describe('errors', () => {
+    test('returns empty array for no errors', () => {
+      const comparator = new BuildComparator({ builds: [build1, build2] });
+      expect(comparator.errors).toEqual([]);
+    });
+
+    test('when there is an All budget error, returns that', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        budgets: [{ level: BudgetLevel.ERROR, type: BudgetType.SIZE, maximum: 1, sizeKey: 'gzip' }],
+      });
+      expect(comparator.errors).toEqual(
+        expect.arrayContaining([
+          {
+            name: 'All',
+            result: {
+              sizeKey: 'gzip',
+              passing: false,
+              expected: 1,
+              actual: 163,
+              type: BudgetType.SIZE,
+              level: BudgetLevel.ERROR,
+            },
+          },
+        ])
+      );
+    });
+
+    test('when there are group errors, returns them', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        groups: [
+          {
+            artifactNames: ['churros~burritos~tacos', 'tacos'],
+            budgets: [{ level: BudgetLevel.ERROR, type: BudgetType.SIZE, maximum: 1, sizeKey: 'gzip' }],
+            name: 'group name',
+          },
+        ],
+      });
+      expect(comparator.errors).toEqual(
+        expect.arrayContaining([
+          {
+            name: 'group name',
+            result: {
+              sizeKey: 'gzip',
+              passing: false,
+              expected: 1,
+              actual: 163,
+              type: BudgetType.SIZE,
+              level: BudgetLevel.ERROR,
+            },
+          },
+        ])
+      );
+    });
+
+    test('when there are artifact errors, returns them', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        artifactBudgets: {
+          tacos: [{ level: BudgetLevel.ERROR, type: BudgetType.SIZE, maximum: 30, sizeKey: 'gzip' }],
+        },
+      });
+      expect(comparator.errors).toEqual(
+        expect.arrayContaining([
+          {
+            name: 'tacos',
+            result: {
+              sizeKey: 'gzip',
+              passing: false,
+              expected: 30,
+              actual: 43,
+              type: BudgetType.SIZE,
+              level: BudgetLevel.ERROR,
+            },
+          },
+        ])
+      );
+    });
+
+    test('calculated value is cached', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        budgets: [{ level: BudgetLevel.ERROR, type: BudgetType.SIZE, maximum: 1, sizeKey: 'gzip' }],
+      });
+      expect(comparator.errors).toBe(comparator.errors);
+    });
+  });
+
+  describe('warnings', () => {
+    test('returns empty array for no warnings', () => {
+      const comparator = new BuildComparator({ builds: [build1, build2] });
+      expect(comparator.warnings).toEqual([]);
+    });
+
+    test('when there is an All budget warning, returns that', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        budgets: [{ level: BudgetLevel.WARN, type: BudgetType.SIZE, maximum: 1, sizeKey: 'gzip' }],
+      });
+      expect(comparator.warnings).toEqual(
+        expect.arrayContaining([
+          {
+            name: 'All',
+            result: {
+              sizeKey: 'gzip',
+              passing: false,
+              expected: 1,
+              actual: 163,
+              type: BudgetType.SIZE,
+              level: BudgetLevel.WARN,
+            },
+          },
+        ])
+      );
+    });
+
+    test('when there are group warnings, returns them', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        groups: [
+          {
+            artifactNames: ['churros~burritos~tacos', 'tacos'],
+            budgets: [{ level: BudgetLevel.WARN, type: BudgetType.SIZE, maximum: 1, sizeKey: 'gzip' }],
+            name: 'group name',
+          },
+        ],
+      });
+      expect(comparator.warnings).toEqual(
+        expect.arrayContaining([
+          {
+            name: 'group name',
+            result: {
+              sizeKey: 'gzip',
+              passing: false,
+              expected: 1,
+              actual: 163,
+              type: BudgetType.SIZE,
+              level: BudgetLevel.WARN,
+            },
+          },
+        ])
+      );
+    });
+
+    test('when there are artifact warnings, returns them', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        artifactBudgets: {
+          tacos: [{ level: BudgetLevel.WARN, type: BudgetType.SIZE, maximum: 30, sizeKey: 'gzip' }],
+        },
+      });
+      expect(comparator.warnings).toEqual(
+        expect.arrayContaining([
+          {
+            name: 'tacos',
+            result: {
+              sizeKey: 'gzip',
+              passing: false,
+              expected: 30,
+              actual: 43,
+              type: BudgetType.SIZE,
+              level: BudgetLevel.WARN,
+            },
+          },
+        ])
+      );
+    });
+
+    test('calculated value is cached', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        budgets: [{ level: BudgetLevel.WARN, type: BudgetType.SIZE, maximum: 1, sizeKey: 'gzip' }],
+      });
+      expect(comparator.warnings).toBe(comparator.warnings);
+    });
+  });
+
+  describe('unexpectedHashChanges', () => {
+    test('returns unexpected hash changes', () => {
+      const comparator = new BuildComparator({ builds: [build1, build2b] });
+      expect(comparator.unexpectedHashChanges).toEqual([{ name: 'burritos' }]);
+    });
+
+    test('calculated value is cached', () => {
+      const comparator = new BuildComparator({ builds: [build1, build2b] });
+      expect(comparator.unexpectedHashChanges).toBe(comparator.unexpectedHashChanges);
+    });
+  });
 });