From d49ea20530a2e445cc123728cbbbfba34a0ebbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 15 May 2020 11:51:03 +0100 Subject: [PATCH] feat(ci): report webpack bundle size diffs on PRs --- .travis.yml | 23 +++ scripts/cra-bundle-stats-diff.py | 233 +++++++++++++++++++++++++++++++ scripts/gist-get.py | 56 ++++++++ scripts/gist-upload.py | 53 +++++++ ui/Makefile | 3 + 5 files changed, 368 insertions(+) create mode 100755 scripts/cra-bundle-stats-diff.py create mode 100755 scripts/gist-get.py create mode 100755 scripts/gist-upload.py diff --git a/.travis.yml b/.travis.yml index 005e2c712..4182ac07b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -62,6 +62,18 @@ jobs: after_success: - travis_retry curl -s --connect-timeout 30 --fail https://codecov.io/bash | bash -s -- -F ui + - stage: Test + name: Webpack bundle size diff + if: (repo = prymitive/karma AND type = pull_request) + <<: *DEFAULTS_JS + env: + # GITHUB_TOKEN for CI Bundle size diff comments and gist uploads + - secure: "Or5fiXZfgIsXvzoOdEprRwJ0uwUjMvxGHE3LG+h3OsIBO6WA8vgOqVOjqVHV2dgC9cSjm5A7MX52S7cDqfkQDkgnVHpVxRwDC9n9O4vnaFdCZ4nC+d18z8dikbiwgdeWQ+Wi6RhZENye1Lu5sBaAJ09wYgx9lNdEVpRaTUvUw6grSlESJZSXoxfxWWpmTyx+yPH4sxuWjZ7gCspDX9s9k4fjpY4LkhQQwLlk8wPc2hfDg48e+K1OR6sYB8uRS33Xc4fQtzElzazmaZ0fn77h5ysDgC1g/ko+E2j8HHMbZvFpzYpm1bCpIv1G/0A2ItH7gT3HsuwkDvfH/it56JTCbBWJJ+hTDeswCQNu0h797QM6jv0o5wgKpHR1t+AeM9vDe4Ds0pAXouJz0LJewNOdNvi5O1BZA9OooKc34hwTJs/zj5NwiZuOyPSMhDBGa++Vhsr9K3rPD9+97M2hac6NO6TBVWZjvqJilmkjJs+bKrl//ClBvdhDGkJNDbB+2emdD1/wzpPVJPp3IRhzeEF89IVE58qE+OQnIwtbEZ2W1ct6Ep7ZJdrXWc/VBdJB1ELfUtNmkvWFZD5IJfnb/Z5MS6iespXlV5alPQ7eZ2jNl3tn7uDaCStuQN1tO2wthNnsSU/OkfFRch/Ks3gYC5+v7n8aJMkTYFmHr4Y/xlXBsrA=" + script: + - ./scripts/gist-get.py 6a1f1aa15a7a308dac7ea2726021836c ui + - make -C ui build/stats.json + - ./scripts/cra-bundle-stats-diff.py ui/stats.json ui/build/stats.json + - stage: Lint name: Lint git commit if: (repo = prymitive/karma AND type != pull_request) OR (fork = true AND type = pull_request) @@ -273,6 +285,17 @@ jobs: - travis_retry curl -s --connect-timeout 30 --fail https://cli-assets.heroku.com/install.sh | sh - travis_retry /usr/local/bin/heroku container:release web --app karma-demo + - stage: Build and Deploy + name: Webpack bundle size snapshot + if: repo = prymitive/karma AND type = push AND branch = master + <<: *DEFAULTS_JS + env: + # GITHUB_TOKEN for CI Bundle size diff comments and gist uploads + - secure: "Or5fiXZfgIsXvzoOdEprRwJ0uwUjMvxGHE3LG+h3OsIBO6WA8vgOqVOjqVHV2dgC9cSjm5A7MX52S7cDqfkQDkgnVHpVxRwDC9n9O4vnaFdCZ4nC+d18z8dikbiwgdeWQ+Wi6RhZENye1Lu5sBaAJ09wYgx9lNdEVpRaTUvUw6grSlESJZSXoxfxWWpmTyx+yPH4sxuWjZ7gCspDX9s9k4fjpY4LkhQQwLlk8wPc2hfDg48e+K1OR6sYB8uRS33Xc4fQtzElzazmaZ0fn77h5ysDgC1g/ko+E2j8HHMbZvFpzYpm1bCpIv1G/0A2ItH7gT3HsuwkDvfH/it56JTCbBWJJ+hTDeswCQNu0h797QM6jv0o5wgKpHR1t+AeM9vDe4Ds0pAXouJz0LJewNOdNvi5O1BZA9OooKc34hwTJs/zj5NwiZuOyPSMhDBGa++Vhsr9K3rPD9+97M2hac6NO6TBVWZjvqJilmkjJs+bKrl//ClBvdhDGkJNDbB+2emdD1/wzpPVJPp3IRhzeEF89IVE58qE+OQnIwtbEZ2W1ct6Ep7ZJdrXWc/VBdJB1ELfUtNmkvWFZD5IJfnb/Z5MS6iespXlV5alPQ7eZ2jNl3tn7uDaCStuQN1tO2wthNnsSU/OkfFRch/Ks3gYC5+v7n8aJMkTYFmHr4Y/xlXBsrA=" + script: + - make -C ui build/stats.json + - ./scripts/gist-upload.py 6a1f1aa15a7a308dac7ea2726021836c ui/build/stats.json + - stage: E2E name: Test demo site if: (repo = prymitive/karma AND type != pull_request) OR (fork = true AND type = pull_request) diff --git a/scripts/cra-bundle-stats-diff.py b/scripts/cra-bundle-stats-diff.py new file mode 100755 index 000000000..d380ddbc2 --- /dev/null +++ b/scripts/cra-bundle-stats-diff.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python + + +from collections import namedtuple +import json +import os +import sys +import urllib2 + + +Bundle = namedtuple('Bundle' , 'seq bundleName totalBytes files') + +Diff = namedtuple('Diff', 'status path oldBytes newBytes isBigger diff') +BundleDiff = namedtuple('BundleDiff', 'total files') + + +class Status: + added = 'added' + modified = 'modified' + deleted = 'deleted' + + +def humanize(value): + abbrevs = ( + (1<<20, 'MB'), + (1<<10, 'kB'), + (1, 'bytes') + ) + if value == 1: + return '1 byte' + for factor, suffix in abbrevs: + if value >= factor: + break + return '%d %s' % (value / factor, suffix) + + +def readBundle(path): + bundles = [] + with open(path) as f: + data = json.load(f) + for result in data['results']: + seq = os.path.basename(result['bundleName']).split('.')[0] + bundle = Bundle( + seq=seq, + bundleName=result['bundleName'], + totalBytes=result['totalBytes'], + files=result['files']) + bundles.append(bundle) + return bundles + + +def printRow(diff, element): + if diff.status == Status.added: + status = '+' + elif diff.status == Status.modified: + status = 'M' + else: + status = '-' + + sign = '+' if diff.isBigger else '' + ob = humanize(diff.oldBytes) if diff.oldBytes > 0 else '' + nb = humanize(diff.newBytes) if diff.newBytes > 0 else '' + + return ''' + <{element}>{status} + <{element}>{path} + <{element}>{ob} + <{element}>{nb} + <{element}>{sign}{diff} + '''.format( + element=element, + status=status, path=diff.path, + ob=ob, nb=nb, + sign=sign, diff=humanize(diff.diff)) + + +def printBundleTable(diffs, element): + rows = [] + for diff in diffs: + rows.append(printRow(diff, element)) + return '{body}
'.format(body='\n'.join(rows)) + + +def printBundleDiff(bundleDiff): + summary = printBundleTable([bundleDiff.total], 'th') + details = printBundleTable(bundleDiff.files, 'td') + + return ''' +
+ {summary} + {details} +
+'''.format(summary=summary, details=details) + + +def makeDiff(path, oldBytes, newBytes): + if oldBytes > 0 and newBytes > 0: + status = Status.modified + elif oldBytes == 0: + status = Status.added + else: + status = Status.deleted + return Diff( + status=status, + path=path, + oldBytes=oldBytes, + newBytes=newBytes, + isBigger=newBytes > oldBytes, + diff=newBytes - oldBytes, + ) + + +def diffBundle(ba, bb): + filesDiffs = [] + if ba.totalBytes != bb.totalBytes: + totalDiff = makeDiff(ba.bundleName, ba.totalBytes, bb.totalBytes) + + for aFile in ba.files: + aSize = ba.files[aFile]['size'] + if aFile in bb.files: + bSize = bb.files[aFile]['size'] + if aSize != bSize: + filesDiffs.append(makeDiff(aFile, aSize, bSize)) + else: + filesDiffs.append(makeDiff(aFile, aSize, 0)) + + for bFile in bb.files: + bSize = bb.files[bFile]['size'] + if bFile not in ba.files: + filesDiffs.append(makeDiff(bFile, 0, bSize)) + + return BundleDiff(total=totalDiff, files=filesDiffs) + + +def summarize(diffs): + totalDiff = 0 + for diff in diffs: + totalDiff += diff.total.diff + sign = '+' if totalDiff > 0 else '' + return '### Total diff: {sign}{totalDiff}'.format( + sign=sign, totalDiff=humanize(totalDiff)) + + +def postComment(diffs, pr, token, owner='prymitive', repo='karma'): + api = 'api.github.com' + uri = 'https://{api}/repos/{owner}/{repo}/issues/{pr}/comments'.format( + api=api, owner=owner, repo=repo, pr=pr) + + rows = [] + for diff in diffs: + rows.append(printBundleDiff(diff)) + summary = summarize(diffs) + data = '*Webpack bundle size diff*\n{summary}\n{rows}'.format( + summary=summary, rows='\n'.join(rows)) + encoded = json.dumps({'body': data}) + + req = urllib2.Request(uri) + req.add_header('Authorization', 'token %s' % token) + req.add_header("Content-Type", "application/json") + try: + response = urllib2.urlopen(req, encoded) + except Exception as e: + print("Request to '%s' failed: %s" % (uri, e)) + + +def diffBundles(a, b): + diffs = [] + for ba in a: + found = False + for bb in b: + if ba.seq == bb.seq: + found = True + d = diffBundle(ba, bb) + if d: + diffs.append(d) + break + if not found: + bb = Bundle( + seq=ba.seq, + bundleName=ba.bundleName, + totalBytes=0, + files={} + ) + d = diffBundle(ba, bb) + if d: + diffs.append(d) + + for bb in b: + found = False + for ba in a: + if bb.seq == ba.seq: + found = True + if not found: + ba = Bundle( + seq=bb.seq, + bundleName=bb.bundleName, + totalBytes=0, + files={} + ) + d = diffBundle(ba, bb) + if d: + diffs.append(d) + + diffs.sort(key=lambda x: x.total.newBytes, reverse=True) + return diffs + + +if __name__ == '__main__': + if len(sys.argv) != 3: + print('Usage: PATH1 PATH2') + sys.exit(1) + + token = os.getenv('GITHUB_TOKEN') + pr = os.getenv('TRAVIS_PULL_REQUEST') + bundleA = readBundle(sys.argv[1]) + bundleB = readBundle(sys.argv[2]) + + if not token: + print('GITHUB_TOKEN env variable is missing') + sys.exit(1) + if not pr: + print('TRAVIS_PULL_REQUEST env variable is missing') + sys.exit(1) + if not bundleA or not bundleB: + print('Usage: PATH1 PATH2') + sys.exit(1) + + diffs = diffBundles(bundleA, bundleB) + if diffs: + print('Found diffs, posting to GitHub') + postComment(diffs, pr, token) + else: + print('No diff found') diff --git a/scripts/gist-get.py b/scripts/gist-get.py new file mode 100755 index 000000000..32f94a0aa --- /dev/null +++ b/scripts/gist-get.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + + +import json +import os +import sys +import urllib2 + + +def downloadFile(workdir, path, uri, token): + req = urllib2.Request(uri) + req.add_header('Authorization', 'token %s' % token) + try: + response = urllib2.urlopen(req) + except Exception as e: + print("Request to '%s' failed: %s" % (uri, e)) + else: + with open(os.path.join(workdir, path), "wb") as local_file: + local_file.write(response.read()) + + +def getGist(workdir, gist_id, token): + api = 'api.github.com' + uri = 'https://{api}/gists/{gist_id}'.format(api=api, gist_id=gist_id) + + req = urllib2.Request(uri) + req.add_header('Authorization', 'token %s' % token) + try: + response = urllib2.urlopen(req) + except Exception as e: + print("Request to '%s' failed: %s" % (uri, e)) + else: + data = json.load(response) + for filename, meta in data['files'].items(): + print('Fetching {filename} from {uri}'.format( + filename=filename, uri=meta['raw_url'])) + downloadFile(workdir, filename, meta['raw_url'], token) + + +if __name__ == '__main__': + if len(sys.argv) != 3: + print('Usage: GIST_ID WORKDIR') + sys.exit(1) + + token = os.getenv('GITHUB_TOKEN') + if not token: + print('GITHUB_TOKEN env variable is missing') + sys.exit(1) + + gist_id = sys.argv[1] + workdir = sys.argv[2] + if not gist_id or not workdir: + print('Usage: GIST_ID WORKDIR') + sys.exit(1) + + getGist(workdir, gist_id, token) diff --git a/scripts/gist-upload.py b/scripts/gist-upload.py new file mode 100755 index 000000000..ceda5cf14 --- /dev/null +++ b/scripts/gist-upload.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + + +import json +import os +import sys +import urllib2 + + +def patchGist(filepath, gist_id, token): + api = 'api.github.com' + uri = 'https://{api}/gists/{gist_id}'.format(api=api, gist_id=gist_id) + + with open(filepath) as f: + data = f.read() + + filename = os.path.basename(filepath) + payload = { + "description": "Webpack bundle size stats", + "files": { + filename: { + "content": data, + "filename": filename + } + } + } + + req = urllib2.Request(uri) + req.get_method = lambda: 'PATCH' + req.add_header('Authorization', 'token %s' % token) + try: + response = urllib2.urlopen(req, json.dumps(payload)) + except Exception as e: + print("Request to '%s' failed: %s" % (uri, e)) + + +if __name__ == '__main__': + if len(sys.argv) != 3: + print('Usage: GIST_ID FILE') + sys.exit(1) + + token = os.getenv('GITHUB_TOKEN') + if not token: + print('GITHUB_TOKEN env variable is missing') + sys.exit(1) + + gist_id = sys.argv[1] + filepath = sys.argv[2] + if not gist_id or not filepath: + print('Usage: GIST_ID FILE') + sys.exit(1) + + patchGist(filepath, gist_id, token) diff --git a/ui/Makefile b/ui/Makefile index ded4bd883..68cbb4ec9 100644 --- a/ui/Makefile +++ b/ui/Makefile @@ -40,3 +40,6 @@ lint-docs: node_modules/markdownlint-cli/markdownlint.js .PHONY: format format: node_modules/prettier/bin-prettier.js node_modules/prettier/bin-prettier.js --write 'src/**/*.js' 'src/**/*.tsx' + +build/stats.json: build + node_modules/source-map-explorer/dist/cli.js build/static/*/*.{js,css} --json > build/stats.json