├── .github ├── funding.yml └── workflows │ ├── main.yml │ └── test.yaml ├── .gitignore ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── package-lock.json ├── package.json ├── src ├── create-comment.js └── index.js ├── style.css └── test └── create-comment.test.js /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [] 2 | patreon: bartveneman 3 | open_collective: projectwallace 4 | custom: ['https://www.projectwallace.com/sponsor', 'https://www.paypal.me/bartveneman'] 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CSS Quality 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | cssDiff: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: CSS Analytics Diff 14 | uses: ./ 15 | with: 16 | project-wallace-token: ${{ secrets.PROJECT_WALLACE_TOKEN }} 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | css-path: ./style.css 19 | post-pr-comment: true 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Use Node 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | - name: Install dependencies 16 | run: npm ci 17 | - name: Run tests 18 | run: npm test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bart Veneman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project Wallace Diff Github Action 2 | 3 | This GitHub actions posts your CSS to [projectwallace.com](https://www.projectwallace.com?ref=gh-diff-action), calculates the change between the current state of your project and your PR, and comments the diff in the PR. 4 | 5 | ![Example comment](https://repository-images.githubusercontent.com/249823357/07b48c00-7aba-11ea-8c20-bf349a3005ac) 6 | 7 | ## Usage 8 | 9 | ### Inputs 10 | 11 | | Name | Required | Example | Description | 12 | | ----------------------- | ---------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 13 | | `github-token` | _required_ | `github-token: ${{ secrets.GITHUB_TOKEN }}` | This Action uses this token to post a comment with the diff. | 14 | | `project-wallace-token` | _required_ | `project-wallace-token: ${{ secrets.PROJECT_WALLACE_TOKEN }}` | The webhook token for your project on projectwallace.com. You can find this token in the project settings. You must add this token to your [repository secrets](https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets#creating-encrypted-secrets) first! | 15 | | `css-path` | _required_ | `css-path: ./build/style.css` | Path to the CSS file that should be analyzed and compared to the data on projectwallace.com. | 16 | | `post-pr-comment` | _optional_ | `true` | Whether this action should post a comment to the PR with changes | 17 | 18 | ### Example 19 | 20 | ```yaml 21 | name: CSS Workflow 22 | 23 | on: 24 | pull_request: # only run this action on pull requests 25 | branches: [master] # and only to the master branch 26 | 27 | jobs: 28 | cssDiff: 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Project Wallace Diff 34 | uses: projectwallace/css-diff-action@master 35 | with: 36 | project-wallace-token: ${{ secrets.PROJECT_WALLACE_TOKEN }} 37 | github-token: ${{ secrets.GITHUB_TOKEN }} 38 | css-path: ./build/style.css 39 | ``` 40 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'CSS Analytics Diff' 2 | description: 'Take CSS and get the changes per CSS Analytics metric' 3 | author: 'Bart Veneman (projectwallace.com)' 4 | 5 | inputs: 6 | github-token: 7 | required: true 8 | project-wallace-token: 9 | description: 'Webhook token of the project on projectwallace.com' 10 | required: true 11 | css-path: 12 | description: 'Path to the CSS you want analyzed' 13 | required: true 14 | post-pr-comment: 15 | description: Whether this action should post a comment to the PR with changes 16 | required: true 17 | default: true 18 | 19 | runs: 20 | using: 'node12' 21 | main: 'dist/index.js' 22 | 23 | branding: 24 | color: 'blue' 25 | icon: 'bar-chart-2' 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@projectwallace/css-diff-action", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "GitHub Action to report the CSS Analytics diff in a PR", 6 | "main": "dist/index.js", 7 | "scripts": { 8 | "test": "ava", 9 | "build": "ncc build src/index.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/bartveneman/gh-action-css-diff.git" 14 | }, 15 | "keywords": [], 16 | "author": "Bart Veneman", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/bartveneman/gh-action-css-diff/issues" 20 | }, 21 | "homepage": "https://github.com/bartveneman/gh-action-css-diff#readme", 22 | "devDependencies": { 23 | "@actions/core": "^1.6.0", 24 | "@actions/github": "^2.2.0", 25 | "@vercel/ncc": "^0.33.3", 26 | "ava": "^3.5.1", 27 | "got": "^10.7.0" 28 | }, 29 | "dependencies": { 30 | "@sentry/node": "^7.16.0", 31 | "pretty-bytes": "^5.3.0" 32 | }, 33 | "prettier": { 34 | "trailingComma": "es5", 35 | "bracketSpacing": true, 36 | "arrowParens": "always", 37 | "semi": false, 38 | "useTabs": true, 39 | "singleQuote": true 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/create-comment.js: -------------------------------------------------------------------------------- 1 | const prettyBytes = require('pretty-bytes') 2 | 3 | const DEPRECATED_METRICS = ['stylesheets.size', 'atrules.fontfaces.unique', 'atrules.fontface.unique'] 4 | 5 | function formatNumber(number) { 6 | return Number.isInteger(number) 7 | ? new Intl.NumberFormat().format(number) 8 | : parseFloat(number).toFixed(3) 9 | } 10 | 11 | function formatFilesize(number, relative = false) { 12 | if ( 13 | number === null || 14 | Number.isNaN(number) || 15 | typeof number === 'undefined' 16 | ) { 17 | return '' 18 | } 19 | 20 | return prettyBytes(number, { signed: relative }) 21 | } 22 | 23 | function formatDiff(number) { 24 | if ( 25 | number === null || 26 | Number.isNaN(number) || 27 | typeof number === 'undefined' 28 | ) { 29 | return '' 30 | } 31 | 32 | if (number === 0) { 33 | return 0 34 | } 35 | 36 | return number > 0 ? `+${formatNumber(number)}` : formatNumber(number) 37 | } 38 | 39 | function formatPercentage(number, decimals = 2) { 40 | if ( 41 | number === null || 42 | Number.isNaN(number) || 43 | typeof number === 'undefined' 44 | ) { 45 | return '' 46 | } 47 | 48 | if (number === Infinity) { 49 | return '∞' 50 | } 51 | 52 | return `${number > 0 ? '+' : ''}${new Intl.NumberFormat('en-US', { 53 | style: 'percent', 54 | maximumFractionDigits: decimals, 55 | }).format(number)}` 56 | } 57 | 58 | function formatListItem({ key, value }) { 59 | if (key === 'atrules.fontfaces.unique') { 60 | return `
${Object.entries(value) 61 | .map(([prop, val]) => { 62 | return `
${prop}
${val}
` 63 | }) 64 | .join('')}
` 65 | } 66 | 67 | return `${value}` 68 | } 69 | 70 | exports.createCommentMarkdown = ({ changes }) => { 71 | if (changes.length === 0) { 72 | return 'No changes in CSS Analytics detected' 73 | } 74 | 75 | return ` 76 | ### CSS Analytics changes 77 | 78 | _Last updated: ${new Date().toISOString()}_ 79 | 80 | | metric | current value | value after PR | difference | 81 | |--------|---------------|----------------|------------| 82 | ${changes 83 | .filter(({ key }) => !DEPRECATED_METRICS.includes(key)) 84 | .map(({ title, diff, aggregate, key }) => { 85 | if (aggregate === 'list') { 86 | const oldValues = diff.diff 87 | .map((item) => { 88 | if (item.added) return `
  • ` 89 | if (item.removed) 90 | return `
  • ${formatListItem({ 91 | key, 92 | value: item.value, 93 | })}
  • ` 94 | return `
  • ${formatListItem({ key, value: item.value })}
  • ` 95 | }) 96 | .join('') 97 | const newValues = diff.diff 98 | .map((item) => { 99 | if (item.removed) return `
  • ` 100 | if (item.added) 101 | return `
  • ${formatListItem({ 102 | key, 103 | value: item.value, 104 | })}
  • ` 105 | return `
  • ${formatListItem({ key, value: item.value })}
  • ` 106 | }) 107 | .join('') 108 | return `| ${title} |
      ${oldValues}
    |
      ${newValues}
    | |` 109 | } 110 | 111 | if (key.includes('size')) { 112 | return `| ${title} | ${formatFilesize( 113 | diff.oldValue 114 | )} | ${formatFilesize(diff.newValue)} | ${formatFilesize( 115 | diff.diff.absolute, 116 | true 117 | )} (${formatPercentage(diff.diff.relative)}) |` 118 | } 119 | 120 | return `| ${title} | ${formatNumber(diff.oldValue)} | ${formatNumber( 121 | diff.newValue 122 | )} | ${formatDiff(diff.diff.absolute)} (${formatPercentage( 123 | diff.diff.relative 124 | )}) |` 125 | }) 126 | .join('\n')} 127 | ` 128 | .split('\n') 129 | .map((line) => line.trim()) 130 | .join('\n') 131 | .trim() 132 | } 133 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const got = require('got') 3 | const core = require('@actions/core') 4 | const github = require('@actions/github') 5 | const Sentry = require("@sentry/node") 6 | const { createCommentMarkdown } = require('./create-comment') 7 | 8 | Sentry.init({ 9 | dsn: "https://5f217579acd24d33af01995ed625d981@o50610.ingest.sentry.io/4504022978199552", 10 | }); 11 | 12 | async function run() { 13 | try { 14 | const cssPath = core.getInput('css-path') 15 | const webhookToken = core.getInput('project-wallace-token') 16 | const githubToken = core.getInput('github-token') 17 | const shouldPostPrComment = core.getInput('post-pr-comment') === 'true' 18 | const { eventName, payload } = github.context 19 | 20 | if (eventName !== 'pull_request') return 21 | if (!shouldPostPrComment) return 22 | 23 | // Read CSS file 24 | const css = fs.readFileSync(cssPath, 'utf8') 25 | 26 | const redactedToken = '*'.padStart(16, '*') + webhookToken.slice(16) 27 | 28 | // POST CSS to projectwallace.com to get the diff 29 | const response = await got( 30 | `https://www.projectwallace.com/api/webhooks/v2/imports/preview?token=${webhookToken}`, 31 | { 32 | method: 'post', 33 | headers: { 34 | 'Content-Type': 'text/css', 35 | Accept: 'application/json', 36 | }, 37 | body: css, 38 | } 39 | ).catch((error) => { 40 | Sentry.captureException(error, { 41 | tags: { 42 | step: 'fetching diff', 43 | token: redactedToken, 44 | } 45 | }) 46 | core.setFailed(`Could not retrieve diff from projectwallace.com`) 47 | throw error 48 | }) 49 | 50 | let diff 51 | 52 | try { 53 | const parsed = JSON.parse(response.body) 54 | diff = parsed.diff 55 | } catch (error) { 56 | Sentry.captureException(error, { 57 | tags: { 58 | step: 'parsing diff', 59 | token: redactedToken, 60 | } 61 | }) 62 | console.error('Cannot parse JSON response from projectwallace.com') 63 | core.setFailed(error.message) 64 | } 65 | 66 | // POST the actual PR comment 67 | const formattedBody = createCommentMarkdown({ changes: diff }) 68 | const owner = payload.repository.owner.login 69 | const repo = payload.repository.name 70 | const issue_number = payload.number 71 | 72 | const octokit = new github.GitHub(githubToken) 73 | let wallaceComment 74 | 75 | try { 76 | const response = await octokit.issues.listComments({ 77 | owner, 78 | repo, 79 | issue_number, 80 | }) 81 | const comments = response.data 82 | wallaceComment = comments.find(comment => comment.body.toLowerCase().includes('css analytics changes') || comment.body.includes('No changes in CSS Analytics detected')) 83 | } catch (error) { 84 | Sentry.captureException(error, { 85 | tags: { 86 | step: 'fetching comment', 87 | owner, 88 | repo, 89 | issue_number, 90 | } 91 | }) 92 | console.error('error fetching PW comment') 93 | console.error(error) 94 | } 95 | 96 | if (wallaceComment) { 97 | console.log(`Updating comment ID ${wallaceComment.id}`) 98 | await octokit.issues.updateComment({ 99 | owner, 100 | repo, 101 | issue_number, 102 | comment_id: wallaceComment.id, 103 | body: formattedBody, 104 | }) 105 | .catch((error) => { 106 | Sentry.captureException(error, { 107 | tags: { 108 | step: 'updating comment', 109 | owner, 110 | repo, 111 | issue_number, 112 | } 113 | }) 114 | core.warning(`Error ${error}: Failed to update comment to PR`) 115 | throw error 116 | }) 117 | } else { 118 | await octokit.issues 119 | .createComment({ 120 | owner, 121 | repo, 122 | issue_number, 123 | body: formattedBody, 124 | }) 125 | .catch((error) => { 126 | Sentry.captureException(error, { 127 | tags: { 128 | step: 'posting comment', 129 | owner, 130 | repo, 131 | issue_number, 132 | } 133 | }) 134 | core.warning(`Error ${error}: Failed to post new comment to PR`) 135 | throw error 136 | }) 137 | } 138 | } catch (error) { 139 | Sentry.captureException(error, { 140 | tags: { 141 | step: 'general error' 142 | } 143 | }) 144 | core.setFailed(error.message) 145 | } 146 | } 147 | 148 | run() 149 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 100%; 3 | } 4 | 5 | body { 6 | background-color: aqua; 7 | } 8 | 9 | p { 10 | color: red; 11 | } 12 | 13 | code { 14 | font-family: monospace, monospace; 15 | } 16 | 17 | @font-face { 18 | src: url('somewhere.woff2'); 19 | font-family: test; 20 | } -------------------------------------------------------------------------------- /test/create-comment.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { createCommentMarkdown } = require('../src/create-comment') 3 | 4 | const diffFixture = [ 5 | { 6 | title: 'Filesize (raw bytes)', 7 | aggregate: 'sum', 8 | key: 'stylesheet.size', 9 | diff: { 10 | oldValue: 9, 11 | newValue: 100, 12 | diff: { absolute: 91, relative: 10.11111111111111 }, 13 | }, 14 | }, 15 | { 16 | title: 'Source Lines of Code', 17 | aggregate: 'sum', 18 | key: 'stylesheet.sourceLinesOfCode', 19 | diff: { oldValue: 1, newValue: 9, diff: { absolute: 8, relative: 8 } }, 20 | }, 21 | { 22 | title: '@font-faces', 23 | aggregate: 'sum', 24 | key: 'atrules.fontface.total', 25 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 26 | }, 27 | { 28 | title: 'Unique @font-faces', 29 | aggregate: 'sum', 30 | key: 'atrules.fontface.totalUnique', 31 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 32 | }, 33 | { 34 | title: '@media queries', 35 | aggregate: 'sum', 36 | key: 'atrules.media.total', 37 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 38 | }, 39 | { 40 | title: 'Unique @media queries', 41 | aggregate: 'sum', 42 | key: 'atrules.media.totalUnique', 43 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 44 | }, 45 | { 46 | title: '@media queries', 47 | aggregate: 'list', 48 | key: 'atrules.media.unique', 49 | diff: { 50 | diff: [ 51 | { value: '(min-width: 30em)', added: true, removed: false, changed: true }, 52 | { value: 'all', added: false, removed: false, changed: false }, 53 | { value: 'only screen', added: false, removed: true, changed: true } 54 | ] 55 | } 56 | }, 57 | { 58 | title: 'Rules', 59 | aggregate: 'sum', 60 | key: 'rules.total', 61 | diff: { oldValue: 1, newValue: 3, diff: { absolute: 2, relative: 2 } }, 62 | }, 63 | { 64 | title: 'Empty rules', 65 | aggregate: 'sum', 66 | key: 'rules.empty.total', 67 | diff: { oldValue: 1, newValue: 0, diff: { absolute: -1, relative: -1 } }, 68 | }, 69 | { 70 | title: 'Minimum size per rule', 71 | aggregate: 'min', 72 | key: 'rules.sizes.min', 73 | diff: { oldValue: 1, newValue: 2, diff: { absolute: 1, relative: 1 } }, 74 | }, 75 | { 76 | title: 'Maximum size per rule', 77 | aggregate: 'max', 78 | key: 'rules.sizes.max', 79 | diff: { oldValue: 1, newValue: 2, diff: { absolute: 1, relative: 1 } }, 80 | }, 81 | { 82 | title: 'Average size per rule', 83 | aggregate: 'average', 84 | key: 'rules.sizes.mean', 85 | diff: { oldValue: 1, newValue: 2, diff: { absolute: 1, relative: 1 } }, 86 | }, 87 | { 88 | title: 'Most common size per rule', 89 | aggregate: 'average', 90 | key: 'rules.sizes.mode', 91 | diff: { oldValue: 1, newValue: 2, diff: { absolute: 1, relative: 1 } }, 92 | }, 93 | { 94 | title: 'RuleSet sizes', 95 | aggregate: 'list', 96 | key: 'rules.sizes.unique', 97 | diff: { 98 | diff: [ 99 | { value: '0', added: false, removed: true, changed: true }, 100 | { value: '2', added: true, removed: false, changed: true }, 101 | ], 102 | }, 103 | }, 104 | { 105 | title: 'Selectors per RuleSet', 106 | aggregate: 'list', 107 | key: 'rules.selectors.unique', 108 | diff: { 109 | diff: [ 110 | { value: '0', added: false, removed: true, changed: true }, 111 | { value: '1', added: true, removed: false, changed: true }, 112 | ], 113 | }, 114 | }, 115 | { 116 | title: 'Minimum declarations per rule', 117 | aggregate: 'min', 118 | key: 'rules.declarations.min', 119 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 120 | }, 121 | { 122 | title: 'Maximum declarations per rule', 123 | aggregate: 'max', 124 | key: 'rules.declarations.max', 125 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 126 | }, 127 | { 128 | title: 'Average declarations per rule', 129 | aggregate: 'average', 130 | key: 'rules.declarations.mean', 131 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 132 | }, 133 | { 134 | title: 'Most common declarations per rule', 135 | aggregate: 'average', 136 | key: 'rules.declarations.mode', 137 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 138 | }, 139 | { 140 | title: 'Declarations per RuleSet', 141 | aggregate: 'list', 142 | key: 'rules.declarations.unique', 143 | diff: { 144 | diff: [ 145 | { value: '0', added: false, removed: true, changed: true }, 146 | { value: '1', added: true, removed: false, changed: true }, 147 | ], 148 | }, 149 | }, 150 | { 151 | title: 'Selectors', 152 | aggregate: 'sum', 153 | key: 'selectors.total', 154 | diff: { oldValue: 1, newValue: 3, diff: { absolute: 2, relative: 2 } }, 155 | }, 156 | { 157 | title: 'Unique selectors', 158 | aggregate: 'sum', 159 | key: 'selectors.totalUnique', 160 | diff: { oldValue: 1, newValue: 3, diff: { absolute: 2, relative: 2 } }, 161 | }, 162 | { 163 | title: 'Total Selector Complexity', 164 | aggregate: 'sum', 165 | key: 'selectors.complexity.sum', 166 | diff: { oldValue: 1, newValue: 3, diff: { absolute: 2, relative: 2 } }, 167 | }, 168 | { 169 | title: 'Unique Selector Complexities', 170 | aggregate: 'list', 171 | key: 'selectors.complexity.unique', 172 | diff: { 173 | diff: [ 174 | { value: '0', added: false, removed: true, changed: true }, 175 | { value: '1', added: true, removed: false, changed: true }, 176 | ], 177 | }, 178 | }, 179 | { 180 | title: 'ID selectors', 181 | aggregate: 'sum', 182 | key: 'selectors.id.total', 183 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 184 | }, 185 | { 186 | title: 'Unique ID Selectors', 187 | aggregate: 'sum', 188 | key: 'selectors.id.totalUnique', 189 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 190 | }, 191 | { 192 | title: 'Declarations', 193 | aggregate: 'sum', 194 | key: 'declarations.total', 195 | diff: { oldValue: 0, newValue: 4, diff: { absolute: 4, relative: 1 } }, 196 | }, 197 | { 198 | title: 'Unique declarations', 199 | aggregate: 'sum', 200 | key: 'declarations.totalUnique', 201 | diff: { oldValue: 0, newValue: 4, diff: { absolute: 4, relative: 1 } }, 202 | }, 203 | { 204 | title: 'Properties', 205 | aggregate: 'sum', 206 | key: 'properties.total', 207 | diff: { oldValue: 0, newValue: 4, diff: { absolute: 4, relative: 1 } }, 208 | }, 209 | { 210 | title: 'Unique properties', 211 | aggregate: 'sum', 212 | key: 'properties.totalUnique', 213 | diff: { oldValue: 0, newValue: 4, diff: { absolute: 4, relative: 1 } }, 214 | }, 215 | { 216 | title: 'Properties', 217 | aggregate: 'list', 218 | key: 'properties.unique', 219 | diff: { 220 | diff: [ 221 | { value: 'color', added: true, removed: false, changed: true }, 222 | { value: 'font-family', added: true, removed: false, changed: true }, 223 | { value: 'font-size', added: true, removed: false, changed: true }, 224 | { value: 'x', added: true, removed: false, changed: true }, 225 | ], 226 | }, 227 | }, 228 | { 229 | title: 'Property complexity', 230 | aggregate: 'sum', 231 | key: 'properties.complexity.sum', 232 | diff: { oldValue: 0, newValue: 4, diff: { absolute: 4, relative: 1 } }, 233 | }, 234 | { 235 | title: 'Max. property complexity', 236 | aggregate: 'max', 237 | key: 'properties.complexity.max', 238 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 239 | }, 240 | { 241 | title: 'Min. property complexity', 242 | aggregate: 'min', 243 | key: 'properties.complexity.min', 244 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 245 | }, 246 | { 247 | title: 'Avg. property complexity', 248 | aggregate: 'average', 249 | key: 'properties.complexity.mean', 250 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 251 | }, 252 | { 253 | title: 'Most common property complexity', 254 | aggregate: 'average', 255 | key: 'properties.complexity.mode', 256 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 257 | }, 258 | { 259 | title: 'Font-sizes', 260 | aggregate: 'sum', 261 | key: 'values.fontSizes.total', 262 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 263 | }, 264 | { 265 | title: 'Unique font-sizes', 266 | aggregate: 'sum', 267 | key: 'values.fontSizes.totalUnique', 268 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 269 | }, 270 | { 271 | title: 'Font-sizes', 272 | aggregate: 'list', 273 | key: 'values.fontSizes.unique', 274 | diff: { 275 | diff: [{ value: '3em', added: true, removed: false, changed: true }], 276 | }, 277 | }, 278 | { 279 | title: 'Font-families', 280 | aggregate: 'sum', 281 | key: 'values.fontFamilies.total', 282 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 283 | }, 284 | { 285 | title: 'Unique font-families', 286 | aggregate: 'sum', 287 | key: 'values.fontFamilies.totalUnique', 288 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 289 | }, 290 | { 291 | title: 'Font-families', 292 | aggregate: 'list', 293 | key: 'values.fontFamilies.unique', 294 | diff: { 295 | diff: [{ value: '"x"', added: true, removed: false, changed: true }], 296 | }, 297 | }, 298 | { 299 | title: 'Colors', 300 | aggregate: 'sum', 301 | key: 'values.colors.total', 302 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 303 | }, 304 | { 305 | title: 'Unique colors', 306 | aggregate: 'sum', 307 | key: 'values.colors.totalUnique', 308 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 309 | }, 310 | { 311 | title: 'Colors', 312 | aggregate: 'list', 313 | key: 'values.colors.unique', 314 | diff: { 315 | diff: [{ value: 'red', added: true, removed: false, changed: true }], 316 | }, 317 | }, 318 | { 319 | title: 'Units', 320 | aggregate: 'sum', 321 | key: 'values.units.total', 322 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 323 | }, 324 | { 325 | title: 'Unique units', 326 | aggregate: 'sum', 327 | key: 'values.units.totalUnique', 328 | diff: { oldValue: 0, newValue: 1, diff: { absolute: 1, relative: 1 } }, 329 | }, 330 | { 331 | title: 'Units', 332 | aggregate: 'list', 333 | key: 'values.units.unique', 334 | diff: { 335 | diff: [{ value: 'em', added: true, removed: false, changed: true }], 336 | }, 337 | }, 338 | ] 339 | 340 | let actual 341 | 342 | test.beforeEach(() => { 343 | actual = createCommentMarkdown({ changes: diffFixture }) 344 | }) 345 | 346 | test('it shows a table header', (t) => { 347 | t.true( 348 | actual.includes('| metric | current value | value after PR | difference |') 349 | ) 350 | }) 351 | 352 | test('it shows filesize diffs correctly', (t) => { 353 | const expected = '| Filesize (raw bytes) | 9 B | 100 B | +91 B (+1,011.11%) |' 354 | const line = actual 355 | .split('\n') 356 | .find((line) => line.startsWith('| Filesize (raw bytes)')) 357 | 358 | t.is(line, expected) 359 | }) 360 | 361 | test('it shows total diffs correctly', (t) => { 362 | const lines = actual.split('\n') 363 | const rules = lines.find((line) => line.startsWith('| Rules |')) 364 | t.is(rules, '| Rules | 1 | 3 | +2 (+200%) |') 365 | 366 | const selectorComplexity = lines.find((line) => 367 | line.startsWith('| Total Selector Complexity |') 368 | ) 369 | t.is(selectorComplexity, '| Total Selector Complexity | 1 | 3 | +2 (+200%) |') 370 | }) 371 | 372 | test('it shows complex array-like diffs correctly', (t) => { 373 | const lines = actual 374 | .split('\n') 375 | .filter((line) => line.startsWith('| @media queries |')) 376 | const media = lines[lines.length - 1] 377 | t.is( 378 | media, 379 | '| @media queries |
    1. all
    2. only screen
    |
    1. (min-width: 30em)
    2. all
    | |' 380 | ) 381 | }) 382 | --------------------------------------------------------------------------------