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}>
+ <{element}>{path}{element}>
+ <{element}>{ob}{element}>
+ <{element}>{nb}{element}>
+ <{element}>{sign}{diff}{element}>
+
'''.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 ''.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