Repository: paularmstrong/build-tracker (ts)

Issue:
Add custom meta information with bt-cli
<!--
When trying to solve more solutions with Build Tracker, please keep in mind some of the following goals of the project:
* Be lightweight: small project size (single-digit KiBs, gzipped)
* Be easy: too many options in an API can become confusing
* Be clear: the intended purpose of every method should be as obvious as possible
* Is it easy to do this in "userland"? Would it be better off done there?
-->

# Problem

I want to add some custom Meta information to Builds. There is no option for this.

# Solution

* add `--meta` option to `bt-cli create-build` and `upload-build` commands. The value should be either a string-value or a JSON-encoded [BuildMetaItem](https://buildtracker.dev/docs/packages/build#buildmeta)

Pull request description:
[cli] add --meta option for passing extra data into a build
<!--
Thank you so much for contributing to open source and the Build Tracker project!
-->

# Problem

Build Meta can accept any arbitrary data and display it in the UI, assuming it matches the format `{ [key: string]: string | { url: string, value: string } }`. Unfortunately, the CLI does not have any way to set this information.

# Solution

* Adds a `--meta` option that takes a string of JSON-encoded, parses it, and attaches it to the Build.
* Unifies the arguments that are shared between `create-build` and `upload-build`

Fixes #150 

# 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/packages/cli.md b/docs/docs/packages/cli.md
index c39a90a..9f1fa6d 100644
--- a/docs/docs/packages/cli.md
+++ b/docs/docs/packages/cli.md
@@ -170,23 +170,26 @@ Beside the arguments below, if you're running your server with a [`BT_API_AUTH_T
 BT_API_AUTH_TOKEN=my-secret-token bt-cli upload-build
 ```
 
-| option, alias        | description                                             | default                       |
-| -------------------- | ------------------------------------------------------- | ----------------------------- |
-| `--branch`, `-b`     | Set the branch name and do not attempt to read from git | Current git working branch    |
-| `--config`, `-c`     | Set path to the build-tracker CLI config file           | `./build-tracker.config.js`   |
-| `--out`, `-o`        | Write the build to stdout                               | `true`                        |
-| `--parent-revision`  | Manually set the parent revision for the comparison.    | Determined via git-merge-base |
-| `--skip-dirty-check` | Skip the git work tree state check                      | `false`                       |
+| option, alias        | description                                                | default                       |
+| -------------------- | ---------------------------------------------------------- | ----------------------------- |
+| `--branch`, `-b`     | Set the branch name and do not attempt to read from git    | Current git working branch    |
+| `--config`, `-c`     | Set path to the build-tracker CLI config file              | `./build-tracker.config.js`   |
+| `--meta`             | JSON-encoded extra meta information to attach to the build |                               |
+| `--parent-revision`  | Manually set the parent revision for the comparison.       | Determined via git-merge-base |
+| `--skip-dirty-check` | Skip the git work tree state check                         | `false`                       |
 
 ### `create-build`
 
 This command will create a Build object for the current available build. If run independently, it will only output information, but not upload it anywhere. For that, you only need to run `yarn bt-cli upload-build`.
 
-| option, alias        | description                                             | default                     |
-| -------------------- | ------------------------------------------------------- | --------------------------- |
-| `--branch`, `-b`     | Set the branch name and do not attempt to read from git | Current git working branch  |
-| `--config`, `-c`     | Set path to the build-tracker CLI config file           | `./build-tracker.config.js` |
-| `--skip-dirty-check` | Skip the git work tree state check                      | `false`                     |
+| option, alias        | description                                                | default                       |
+| -------------------- | ---------------------------------------------------------- | ----------------------------- |
+| `--branch`, `-b`     | Set the branch name and do not attempt to read from git    | Current git working branch    |
+| `--config`, `-c`     | Set path to the build-tracker CLI config file              | `./build-tracker.config.js`   |
+| `--meta`             | JSON-encoded extra meta information to attach to the build |                               |
+| `--out`, `-o`        | Write the build to stdout                                  | `true`                        |
+| `--parent-revision`  | Manually set the parent revision for the comparison.       | Determined via git-merge-base |
+| `--skip-dirty-check` | Skip the git work tree state check                         | `false`                       |
 
 ### `stat-artifacts`
 
diff --git a/docs/docs/packages/comparator.md b/docs/docs/packages/comparator.md
index 3feec46..8e5fb86 100644
--- a/docs/docs/packages/comparator.md
+++ b/docs/docs/packages/comparator.md
@@ -86,17 +86,19 @@ Returns an array Markdown-formatted summaries of the overall comparison and any
 [
   '⚠️: `Group \\"All\\"` failed the gzip budget size limit of 0 KiB by 0.16 KiB',
   '⚠️: `Group \\"warning\\"` failed the gzip budget size limit of 0 KiB by 0.12 KiB',
-  '🚫: `Group \\"error\\"` failed the gzip budget size limit of 0 KiB by 0.16 KiB'
+  '🚨: `Group \\"error\\"` failed the gzip budget size limit of 0 KiB by 0.16 KiB',
+  '#️⃣: `burritos` hash changed without any file size change'
 ];
 ```
 
 If `useEmoji` is set to `false`, emoji will be replaced with words:
 
-| Emoji        | Word    |
-| ------------ | ------- |
-| ✅ (U+2705)  | Success |
-| ⚠️ (U+26A0)  | Warning |
-| 🚫 (U+1F6AB) | Error   |
+| Emoji            | Word    |
+| ---------------- | ------- |
+| ✅ (U+2705)      | Success |
+| ⚠️ (U+26A0)      | Warning |
+| 🚨 (U+1F6A9)     | Error   |
+| #️⃣ (U+0023-20E3) | Hash    |
 
 ## Types
 
diff --git a/src/app/src/components/ComparisonTable/DeltaCell.tsx b/src/app/src/components/ComparisonTable/DeltaCell.tsx
index 9d349d1..ad00eaa 100644
--- a/src/app/src/components/ComparisonTable/DeltaCell.tsx
+++ b/src/app/src/components/ComparisonTable/DeltaCell.tsx
@@ -3,13 +3,14 @@
  */
 import { BudgetLevel } from '@build-tracker/types';
 import ErrorIcon from '../../icons/Error';
+import HashIcon from '../../icons/Hash';
 import Hoverable from '../Hoverable';
 import React from 'react';
 import { Td } from '../Table';
 import Tooltip from '../Tooltip';
 import WarningIcon from '../../icons/Warning';
 import { DeltaCell as Cell, TotalDeltaCell as TDCell } from '@build-tracker/comparator';
-import { formatBudgetResult, formatBytes } from '@build-tracker/formatting';
+import { formatBudgetResult, formatBytes, formatUnexpectedHashChange } from '@build-tracker/formatting';
 import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
 
 interface Props {
@@ -69,26 +70,35 @@ export const DeltaCell = (props: Props): React.ReactElement => {
   }
 
   const stringChange = `${sizeDelta} bytes (${(percentDelta * 100).toFixed(3)}%)`;
-  const title = cell.hashChanged && sizeDelta === 0 ? `Unexpected hash change! ${stringChange}` : stringChange;
 
-  const text = sizeDelta === 0 ? cell.hashChanged ? <WarningIcon /> : '' : formatBytes(sizeDelta);
+  const text = formatBytes(sizeDelta);
+  const hashChangedOnly = sizeDelta === 0 && cell.hashChanged;
   const tooltipText = failingBudgets.length
     ? failingBudgets.map(budget => formatBudgetResult(budget, cell.name)).join(', ')
-    : sizeDelta === 0 && cell.hashChanged
-    ? `The hash for "${cell.name}" unexpectedly changed`
+    : hashChangedOnly
+    ? formatUnexpectedHashChange(cell.name, false)
     : `"${cell.name}" changed by ${stringChange}`;
 
   return (
-    <Td accessibilityLabel={title} style={[style, { backgroundColor }]}>
+    <Td accessibilityLabel={tooltipText} style={[style, { backgroundColor }]}>
       {text ? (
         <Hoverable>
           {isHovered => (
             <View ref={viewRef} style={styles.textWrapper} testID="delta">
-              <Text>
-                {errorBudgets.length ? <ErrorIcon /> : warningBudgets.length ? <WarningIcon /> : null}
-                {failingBudgets.length ? ' ' : null}
-              </Text>
-              <Text>{text}</Text>
+              {sizeDelta !== 0 || hashChangedOnly ? (
+                <Text>
+                  {errorBudgets.length ? (
+                    <ErrorIcon />
+                  ) : warningBudgets.length ? (
+                    <WarningIcon />
+                  ) : hashChangedOnly ? (
+                    <HashIcon />
+                  ) : (
+                    ''
+                  )}{' '}
+                </Text>
+              ) : null}
+              {sizeDelta !== 0 ? <Text>{text}</Text> : null}
               {isHovered ? <Tooltip relativeTo={viewRef} text={tooltipText} /> : null}
             </View>
           )}
diff --git a/src/app/src/icons/Hash.tsx b/src/app/src/icons/Hash.tsx
new file mode 100644
index 0000000..3d1f703
--- /dev/null
+++ b/src/app/src/icons/Hash.tsx
@@ -0,0 +1,29 @@
+/**
+ * Material design redistributed from https://github.com/Templarian/MaterialDesign-JS
+ *
+ * SVG contents redistributed under MIT License
+ * https://github.com/Templarian/MaterialDesign-JS/blob/master/LICENSE
+ * Copyright 2018 Austin Andrews
+ */
+import React from 'react';
+import styles from './styles';
+import { StyleProp, TextStyle, unstable_createElement, ViewProps } from 'react-native';
+
+interface Props extends ViewProps {
+  style?: StyleProp<TextStyle>;
+}
+
+const Error = (props: Props): React.ReactElement<Props> =>
+  unstable_createElement(
+    'svg',
+    {
+      ...props,
+      style: [styles.root, props.style],
+      viewBox: '0 0 24 24'
+    },
+    <path d="M3,5A2,2 0 0,1 5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5M7,18H9L9.35,16H13.35L13,18H15L15.35,16H17.35L17.71,14H15.71L16.41,10H18.41L18.76,8H16.76L17.12,6H15.12L14.76,8H10.76L11.12,6H9.12L8.76,8H6.76L6.41,10H8.41L7.71,14H5.71L5.35,16H7.35L7,18M10.41,10H14.41L13.71,14H9.71L10.41,10Z" />
+  );
+
+Error.metadata = { height: 24, width: 24 };
+
+export default Error;
diff --git a/src/cli/src/commands/create-build.ts b/src/cli/src/commands/create-build.ts
index 7c67360..10008a1 100644
--- a/src/cli/src/commands/create-build.ts
+++ b/src/cli/src/commands/create-build.ts
@@ -12,9 +12,10 @@ export const command = 'create-build';
 
 export const description = 'Construct a build for the current commit';
 
-interface Args {
+export interface Args {
   branch?: string;
   config?: string;
+  meta?: string;
   out: boolean;
   'parent-revision'?: string;
   'skip-dirty-check': boolean;
@@ -22,9 +23,8 @@ interface Args {
 
 const group = 'Create a build';
 
-export const builder = (yargs): Argv<Args> =>
+export const getBuildOptions = (yargs): Argv<Args> =>
   yargs
-    .usage(`Usage: $0 ${command}`)
     .option('branch', {
       alias: 'b',
       description: 'Set the branch name and do not attempt to read from git',
@@ -37,12 +37,10 @@ export const builder = (yargs): Argv<Args> =>
       group,
       normalize: true
     })
-    .option('out', {
-      alias: 'o',
-      default: true,
-      description: 'Write the build to stdout',
+    .option('meta', {
+      description: 'JSON-encoded extra meta information to attach to the build',
       group,
-      type: 'boolean'
+      type: 'string'
     })
     .option('parent-revision', {
       description: 'Manually provide the parent revision instead of reading it automatically',
@@ -56,6 +54,17 @@ export const builder = (yargs): Argv<Args> =>
       type: 'boolean'
     });
 
+export const builder = (yargs): Argv<Args> =>
+  getBuildOptions(yargs)
+    .usage(`Usage: $0 ${command}`)
+    .option('out', {
+      alias: 'o',
+      default: true,
+      description: 'Write the build to stdout',
+      group,
+      type: 'boolean'
+    });
+
 export const handler = async (args: Args): Promise<{}> => {
   const config = await getConfig(args.config);
   if (!args['skip-dirty-check']) {
@@ -106,6 +115,8 @@ export const handler = async (args: Args): Promise<{}> => {
     }
   }
 
+  const meta = args.meta ? JSON.parse(args.meta) : {};
+
   const build = {
     meta: {
       author: name,
@@ -113,7 +124,8 @@ export const handler = async (args: Args): Promise<{}> => {
       parentRevision,
       revision,
       subject,
-      timestamp
+      timestamp,
+      ...meta
     },
     artifacts
   };
diff --git a/src/cli/src/commands/upload-build.ts b/src/cli/src/commands/upload-build.ts
index 15b5fbe..277e24a 100644
--- a/src/cli/src/commands/upload-build.ts
+++ b/src/cli/src/commands/upload-build.ts
@@ -2,55 +2,23 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import { Argv } from 'yargs';
-import { handler as createBuild } from './create-build';
 import http from 'http';
 import https from 'https';
 import { URL } from 'url';
+import { Args as BuildArgs, handler as createBuildHandler, getBuildOptions } from './create-build';
 import getConfig, { ApiReturn } from '../modules/config';
 
 export const command = 'upload-build';
 
 export const description = 'Upload a build for the current commit';
 
-interface Args {
-  branch?: string;
-  config?: string;
-  out: boolean;
-  'skip-dirty-check': boolean;
-}
+type Args = BuildArgs;
 
-const group = 'Create a build';
-
-export const builder = (yargs): Argv<Args> =>
-  yargs
-    .usage(`Usage: $0 ${command}`)
-    .option('branch', {
-      alias: 'b',
-      description: 'Set the branch name and do not attempt to read from git',
-      group,
-      type: 'string'
-    })
-    .option('config', {
-      alias: 'c',
-      description: 'Override path to the build-tracker CLI config file',
-      group,
-      normalize: true
-    })
-    .option('parent-revision', {
-      description: 'Manually provide the parent revision instead of reading it automatically',
-      group,
-      type: 'string'
-    })
-    .option('skip-dirty-check', {
-      default: false,
-      description: 'Skip the git work tree state check',
-      group,
-      type: 'boolean'
-    });
+export const builder = (yargs): Argv<Args> => getBuildOptions(yargs).usage(`Usage: $0 ${command}`);
 
 export const handler = async (args: Args): Promise<void> => {
   const config = await getConfig(args.config);
-  const build = await createBuild({ ...args, out: false });
+  const build = await createBuildHandler({ ...args, out: false });
 
   const url = new URL(`${config.applicationUrl}/api/builds`);
   const httpProtocol = config.applicationUrl.startsWith('https:') ? https : http;
diff --git a/src/comparator/src/index.ts b/src/comparator/src/index.ts
index 924b15d..33f4e84 100644
--- a/src/comparator/src/index.ts
+++ b/src/comparator/src/index.ts
@@ -5,7 +5,13 @@ import Build from '@build-tracker/build';
 import BuildDelta from './BuildDelta';
 import markdownTable from 'markdown-table';
 import { ArtifactBudgets, ArtifactFilters, Budget, BudgetLevel, BudgetResult, Group } from '@build-tracker/types';
-import { formatBudgetResult, formatBytes, formatSha } from '@build-tracker/formatting';
+import {
+  formatBudgetResult,
+  formatBytes,
+  formatSha,
+  formatUnexpectedHashChange,
+  levelToEmoji
+} from '@build-tracker/formatting';
 
 export interface ArtifactSizes {
   [key: string]: number;
@@ -112,9 +118,17 @@ const defaultFormatTotal = (cell: TotalCell, sizeKey: string): string => formatB
 const defaultFormatDelta = (cell: DeltaCell | TotalDeltaCell, sizeKey: string): string => {
   const errorFailingBudgets = cell.failingBudgets.some(result => result.level === BudgetLevel.ERROR);
   const warningFailingBudgets = cell.failingBudgets.some(result => result.level === BudgetLevel.WARN);
-  return `${errorFailingBudgets ? '🚨 ' : warningFailingBudgets ? '⚠️ ' : ''}${formatBytes(
-    cell.sizes[sizeKey] || 0
-  )} (${((cell.percents[sizeKey] || 0) * 100).toFixed(1)}%)`;
+  const warningHashChanged =
+    cell.hashChanged && cell.failingBudgets.length === 0 && Object.values(cell.sizes).every(size => size === 0);
+  return `${
+    errorFailingBudgets
+      ? `${levelToEmoji[BudgetLevel.ERROR]} `
+      : warningFailingBudgets
+      ? `${levelToEmoji[BudgetLevel.WARN]} `
+      : warningHashChanged
+      ? `${levelToEmoji['hash']} `
+      : ''
+  }${formatBytes(cell.sizes[sizeKey] || 0)} (${((cell.percents[sizeKey] || 0) * 100).toFixed(1)}%)`;
 };
 const defaultArtifactFilter = (): boolean => true;
 
@@ -470,6 +484,10 @@ export default class BuildComparator {
         cell.failingBudgets.forEach(budget => {
           memo.push(formatBudgetResult(budget, row[0].text, useEmoji));
         });
+
+        if (cell.failingBudgets.length === 0 && Object.values(cell.sizes).every(size => size === 0)) {
+          memo.push(formatUnexpectedHashChange(row[0].text, useEmoji));
+        }
       });
       return memo;
     }, []);
diff --git a/src/formatting/src/index.ts b/src/formatting/src/index.ts
index fd231b8..a235405 100644
--- a/src/formatting/src/index.ts
+++ b/src/formatting/src/index.ts
@@ -35,22 +35,28 @@ function formatPercent(value: number): string {
 
 const levelToString = {
   [BudgetLevel.WARN]: 'Warning',
-  [BudgetLevel.ERROR]: 'Error'
+  [BudgetLevel.ERROR]: 'Error',
+  hash: 'Hash'
 };
 
-const levelToEmoji = {
+export const levelToEmoji = {
   [BudgetLevel.WARN]: '⚠️',
-  [BudgetLevel.ERROR]: '🚫'
+  [BudgetLevel.ERROR]: '🚨',
+  hash: '#️⃣'
 };
 
-export function formatBudgetResult(budgetResult: BudgetResult, itemName: string, useEmoji = false): string {
+function getPrefix(artifactName: string, level: string, useEmoji = false): string {
+  return `${(useEmoji ? levelToEmoji : levelToString)[level]}: \`${artifactName}\``;
+}
+
+export function formatBudgetResult(budgetResult: BudgetResult, artifactName: string, useEmoji = false): string {
   const { actual, expected, level, sizeKey, type } = budgetResult;
   const actualFormatted = type === BudgetType.PERCENT_DELTA ? formatPercent(actual) : formatBytes(actual);
   const expectedFormatted = type === BudgetType.PERCENT_DELTA ? formatPercent(expected) : formatBytes(expected);
   const diffFormatted =
     type === BudgetType.PERCENT_DELTA ? formatPercent(actual - expected) : formatBytes(actual - expected);
 
-  const prefix = `${(useEmoji ? levelToEmoji : levelToString)[level]}: \`${itemName}\``;
+  const prefix = getPrefix(artifactName, level, useEmoji);
 
   switch (type) {
     case BudgetType.DELTA:
@@ -61,3 +67,8 @@ export function formatBudgetResult(budgetResult: BudgetResult, itemName: string,
       return `${prefix} failed the ${sizeKey} budget size limit of ${expectedFormatted} by ${diffFormatted}`;
   }
 }
+
+export function formatUnexpectedHashChange(artifactName: string, useEmoji = false): string {
+  const prefix = getPrefix(artifactName, 'hash', useEmoji);
+  return `${prefix} hash changed without any file size change`;
+}


Test patch:
diff --git a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
index 9add2a6..be9e901 100644
--- a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
@@ -4,6 +4,7 @@
 import { CellType } from '@build-tracker/comparator';
 import { DeltaCell } from '../DeltaCell';
 import ErrorIcon from '../../../icons/Error';
+import HashIcon from '../../../icons/Hash';
 import React from 'react';
 import { Td } from '../../Table';
 import WarningIcon from '../../../icons/Warning';
@@ -65,7 +66,7 @@ describe('DeltaCell', () => {
         />
       );
 
-      expect(getByType(Td).props.accessibilityLabel).toEqual('-134 bytes (-50.000%)');
+      expect(getByType(Td).props.accessibilityLabel).toEqual('"tacos" changed by -134 bytes (-50.000%)');
     });
 
     test('shows a warning label if no change, but hash changed', () => {
@@ -83,8 +84,8 @@ describe('DeltaCell', () => {
           sizeKey="stat"
         />
       );
-      expect(queryAllByType(WarningIcon)).toHaveLength(1);
-      expect(getByType(Td).props.accessibilityLabel).toEqual('Unexpected hash change! 0 bytes (0.000%)');
+      expect(queryAllByType(HashIcon)).toHaveLength(1);
+      expect(getByType(Td).props.accessibilityLabel).toEqual('Hash: `tacos` hash changed without any file size change');
     });
 
     test('shows a warning icon if warning budget fails', () => {
diff --git a/src/cli/src/commands/__tests__/create-build.test.ts b/src/cli/src/commands/__tests__/create-build.test.ts
index 3823fd7..f8e2d46 100644
--- a/src/cli/src/commands/__tests__/create-build.test.ts
+++ b/src/cli/src/commands/__tests__/create-build.test.ts
@@ -158,5 +158,18 @@ describe('create-build', () => {
         }
       });
     });
+
+    test('can include JSON encoded extra meta', async () => {
+      await expect(
+        Command.handler({
+          config,
+          out: false,
+          meta: '{"foo":"bar","baz":{"url":"https://buildtracker.dev","value":"baz"}}',
+          'skip-dirty-check': true
+        })
+      ).resolves.toMatchObject({
+        meta: { revision: 'abcdefg', foo: 'bar', baz: { url: 'https://buildtracker.dev', value: 'baz' } }
+      });
+    });
   });
 });
diff --git a/src/comparator/src/__tests__/index.test.ts b/src/comparator/src/__tests__/index.test.ts
index eb57707..9da6dfe 100644
--- a/src/comparator/src/__tests__/index.test.ts
+++ b/src/comparator/src/__tests__/index.test.ts
@@ -36,6 +36,14 @@ const build2 = new Build(
     { name: 'churros', hash: 'def', sizes: { stat: 469, gzip: 120 } }
   ]
 );
+// Alternative to build2 with burritos
+const build2b = new Build(
+  { branch: 'master', revision: '8901234abcdef', parentRevision: 'abcdef', timestamp: 8901234 },
+  [
+    { name: 'tacos', hash: 'abc', sizes: { stat: 123, gzip: 45 } },
+    { name: 'burritos', hash: 'def', sizes: { stat: 456, gzip: 90 } }
+  ]
+);
 
 const artifactFilters = [/burritos/, /churros/];
 
@@ -368,9 +376,9 @@ describe('BuildComparator', () => {
     test('handles 0 artifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build1b] });
       expect(comparator.toMarkdown({ artifactFilter: () => false })).toMatchInlineSnapshot(`
-      "|     |  1234567 |  1234567 |           Δ1 |
-      | :-- | -------: | -------: | -----------: |
-      | All | 0.13 KiB | 0.13 KiB | 0 KiB (0.0%) |"
+        "|     |  1234567 |  1234567 |           Δ1 |
+        | :-- | -------: | -------: | -----------: |
+        | All | 0.13 KiB | 0.13 KiB | 0 KiB (0.0%) |"
       `);
     });
 
@@ -489,6 +497,19 @@ describe('BuildComparator', () => {
       `);
     });
 
+    test('includes hash emoji for unexpected hash changes', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2b]
+      });
+      expect(comparator.toMarkdown()).toMatchInlineSnapshot(`
+"|          |  1234567 |  8901234 |               Δ1 |
+| :------- | -------: | -------: | ---------------: |
+| All      | 0.13 KiB | 0.13 KiB | #️⃣ 0 KiB (0.0%) |
+| burritos | 0.09 KiB | 0.09 KiB | #️⃣ 0 KiB (0.0%) |
+| tacos    | 0.04 KiB | 0.04 KiB |     0 KiB (0.0%) |"
+`);
+    });
+
     test('includes emoji for failing group budgets', () => {
       const comparator = new BuildComparator({
         builds: [build1, build2],
@@ -562,10 +583,10 @@ describe('BuildComparator', () => {
     test('does not include filtered artifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build2], artifactFilters });
       expect(comparator.toCsv()).toMatchInlineSnapshot(`
-        ",1234567,8901234,Δ1
-        All,0.04 KiB,0.04 KiB,0 KiB (-4.4%)
-        tacos,0.04 KiB,0.04 KiB,0 KiB (-4.4%)"
-      `);
+",1234567,8901234,Δ1
+All,0.04 KiB,0.04 KiB,0 KiB (-4.4%)
+tacos,0.04 KiB,0.04 KiB,0 KiB (-4.4%)"
+`);
     });
 
     test('accepts formatting and filtering options', () => {
@@ -591,7 +612,7 @@ describe('BuildComparator', () => {
   describe('summary', () => {
     test('returns a list of errors and warnings for artifacts', () => {
       const comparator = new BuildComparator({
-        builds: [build1, build2],
+        builds: [build1, build2b],
         artifactBudgets: {
           churros: [{ level: BudgetLevel.WARN, type: BudgetType.SIZE, maximum: 100, sizeKey: 'gzip' }],
           tacos: [
@@ -601,10 +622,10 @@ describe('BuildComparator', () => {
         }
       });
       expect(comparator.toSummary().join('\n')).toMatchInlineSnapshot(`
-        "⚠️: \`churros\` failed the gzip budget size limit of 0.1 KiB by 0.02 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
+⚠️: \`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"
+`);
     });
 
     test('returns a list of errors and warnings for groups', () => {
@@ -627,7 +648,7 @@ describe('BuildComparator', () => {
       expect(comparator.toSummary().join('\n')).toMatchInlineSnapshot(`
         "⚠️: \`Group \\"All\\"\` failed the gzip budget size limit of 0 KiB by 0.16 KiB
         ⚠️: \`Group \\"warning\\"\` failed the gzip budget size limit of 0 KiB by 0.12 KiB
-        🚫: \`Group \\"error\\"\` failed the gzip budget size limit of 0 KiB by 0.16 KiB"
+        🚨: \`Group \\"error\\"\` failed the gzip budget size limit of 0 KiB by 0.16 KiB"
       `);
     });
 
diff --git a/src/formatting/src/__tests__/index.test.ts b/src/formatting/src/__tests__/index.test.ts
index 3a85527..5b1a84d 100644
--- a/src/formatting/src/__tests__/index.test.ts
+++ b/src/formatting/src/__tests__/index.test.ts
@@ -60,7 +60,7 @@ describe('formatBudgetResult', () => {
           'tacos',
           true
         )
-      ).toEqual('🚫: `tacos` failed the stat budget size limit of 2 KiB by 2 KiB');
+      ).toEqual('🚨: `tacos` failed the stat budget size limit of 2 KiB by 2 KiB');
     });
 
     test('size', () => {