├── .gitignore ├── test ├── index.test.ts └── buildBrowseStorybook.test.ts ├── bin ├── upload.js └── index.js ├── src ├── typings.d.ts ├── environment.ts ├── helpers │ ├── static.ts │ ├── format.ts │ └── timing.ts ├── installStorybook.ts ├── installAddonBench.ts ├── upload.ts ├── index.ts ├── startStorybook.ts └── buildBrowseStorybook.ts ├── tsconfig.json ├── .github └── workflows │ ├── release.yml │ └── main.yml ├── LICENSE ├── package.json ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | describe('testing', () => { 2 | it('works', () => { 3 | expect(true).toBe(true); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /bin/upload.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { upload } = require('../dist/index'); 4 | upload().then(() => process.exit()); 5 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'exectimer'; 2 | declare module 'pretty-bytes'; 3 | declare module 'du'; 4 | declare module 'rimraf'; 5 | declare module 'jsonexport'; 6 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { main } = require('../dist/index.js'); 4 | main() 5 | .then(() => process.exit()) 6 | .catch(err => { 7 | console.error(err); 8 | process.exit(1); 9 | }); 10 | -------------------------------------------------------------------------------- /src/environment.ts: -------------------------------------------------------------------------------- 1 | const GCP_CREDENTIALS = JSON.parse(process.env.GCP_CREDENTIALS || '{}'); 2 | const SB_BENCH_UPLOAD = process.env.SB_BENCH_UPLOAD === 'true'; 3 | const { CIRCLE_BRANCH, CIRCLE_SHA1 } = process.env; 4 | 5 | export { SB_BENCH_UPLOAD, GCP_CREDENTIALS, CIRCLE_BRANCH, CIRCLE_SHA1 }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "target": "ES2022", 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "baseUrl": "./", 18 | "paths": { 19 | "*": ["src/*", "node_modules/*"] 20 | }, 21 | "jsx": "react", 22 | "esModuleInterop": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/helpers/static.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi'; 2 | import Inert from '@hapi/inert'; 3 | import path from 'path'; 4 | 5 | export const STATIC_STORYBOOK_PORT = 9899; 6 | 7 | export const makeStaticServer = async () => { 8 | const server = new Hapi.Server({ 9 | host: 'localhost', 10 | port: STATIC_STORYBOOK_PORT, 11 | }); 12 | await server.register(Inert); 13 | 14 | server.route({ 15 | method: 'GET', 16 | path: '/{param*}', 17 | handler: async (req, h) => { 18 | const filePath = path.join(process.cwd(), 'storybook-static', req.path); 19 | return h.file(filePath, { confine: false }); 20 | }, 21 | }); 22 | 23 | await server.start(); 24 | console.log('Static server:', server.info.uri); 25 | return server; 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: [push] 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Prepare repository 13 | run: git fetch --unshallow --tags 14 | 15 | - name: Use Node.js 14.x 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 14.x 19 | 20 | - name: Install dependencies 21 | uses: bahmutov/npm-install@v1 22 | 23 | - name: Create Release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | run: | 28 | yarn release -------------------------------------------------------------------------------- /src/helpers/format.ts: -------------------------------------------------------------------------------- 1 | import mapValues from 'lodash/mapValues'; 2 | import prettyBytes from 'pretty-bytes'; 3 | 4 | const prettyTime = (duration: number) => (duration / 1000000000.0).toFixed(2); 5 | 6 | const mapValuesDeep = (obj: any, formatFn: any): any => 7 | typeof obj === 'object' ? mapValues(obj, val => mapValuesDeep(val, formatFn)) : formatFn(obj); 8 | 9 | export const formatString = (result: Record) => ({ 10 | time: mapValuesDeep(result.time, prettyTime), 11 | size: mapValuesDeep(result.size, prettyBytes), 12 | }); 13 | 14 | const toMS = (val: number) => Math.round(val / 1000000); 15 | const toKB = (val: number) => Math.round(val / 1024); 16 | 17 | export const formatNumber = (result: Record) => 18 | mapValues(result, val => ({ 19 | time: mapValuesDeep(val.time, toMS), 20 | size: mapValuesDeep(val.size, toKB), 21 | })); 22 | -------------------------------------------------------------------------------- /test/buildBrowseStorybook.test.ts: -------------------------------------------------------------------------------- 1 | import { parseDevOutput } from '../src/startStorybook'; 2 | 3 | describe('parseDevOutput', () => { 4 | it('no match', () => { 5 | expect(parseDevOutput('')).toBeUndefined(); 6 | }); 7 | 8 | it('preview only', () => { 9 | expect(parseDevOutput('│ 8.86 s for preview │')).toMatchInlineSnapshot(` 10 | Object { 11 | "manager": 0, 12 | "preview": 8860000000, 13 | } 14 | `); 15 | }); 16 | it('manager + preview', () => { 17 | expect(parseDevOutput('│ 8.42 s for manager and 8.86 s for preview │')).toMatchInlineSnapshot(` 18 | Object { 19 | "manager": 8420000000, 20 | "preview": 8860000000, 21 | } 22 | `); 23 | }); 24 | 25 | it('milliseconds', () => { 26 | expect(parseDevOutput('│ 880 ms for manager and 8.86 s for preview │')).toMatchInlineSnapshot(` 27 | Object { 28 | "manager": 880000000, 29 | "preview": 8860000000, 30 | } 31 | `); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 14 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 14.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /src/installStorybook.ts: -------------------------------------------------------------------------------- 1 | import { sync as spawnSync } from 'cross-spawn'; 2 | import du from 'du'; 3 | import { Tick, timers } from 'exectimer'; 4 | import { installAddonBench } from './installAddonBench'; 5 | 6 | const NODE_MODULES = 'node_modules'; 7 | const STDIO = 'inherit'; 8 | 9 | export const installStorybook = async (installCommand: string) => { 10 | console.log('measuring install'); 11 | if (!installCommand) { 12 | console.warn('No install command provided'); 13 | return { 14 | size: { total: 0 }, 15 | time: { total: 0 }, 16 | }; 17 | } 18 | 19 | const initialSize = await du(NODE_MODULES); 20 | Tick.wrap(function install(done: () => void) { 21 | const [cmd, ...args] = installCommand.split(' '); 22 | spawnSync(cmd, args, { stdio: STDIO }); 23 | done(); 24 | }); 25 | const finalSize = await du(NODE_MODULES); 26 | 27 | // Add instrumentation addon AFTER we've measured install size 28 | await installAddonBench(); 29 | 30 | return { 31 | size: { total: finalSize - initialSize }, 32 | time: { total: timers.install.duration() }, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michael Shilman 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. -------------------------------------------------------------------------------- /src/helpers/timing.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi'; 2 | 3 | export const EVENTS = ['managerRender', 'previewRender']; // , 'storyRender' 4 | export const STATS_PORT = 9898; 5 | 6 | export type Stats = { 7 | init: number; 8 | time: Record; 9 | }; 10 | 11 | export const chromiumArgs = ['--no-sandbox', '--disable-setuid-sandbox']; 12 | 13 | const now = () => new Date().getTime(); 14 | 15 | export const resetStats = (stats?: Stats) => { 16 | const result = stats || ({ init: now(), time: {} } as Stats); 17 | result.init = now(); 18 | EVENTS.forEach(evt => (result.time[evt] = null)); 19 | return result; 20 | }; 21 | 22 | export const makeStatsServer = async (stats: Stats, done: any) => { 23 | const server = new Hapi.Server({ 24 | host: 'localhost', 25 | port: STATS_PORT, 26 | }); 27 | 28 | const addEvent = (event: string) => { 29 | server.route({ 30 | method: 'GET', 31 | path: `/${event}`, 32 | handler: async (req: any, h: any) => { 33 | console.log(`HANDLER: ${event}`, stats); 34 | if (!stats.time[event]) { 35 | stats.time[event] = (now() - stats.init) * 1000000; 36 | } 37 | if (event === 'previewRender') { 38 | done(); 39 | } 40 | return h.response('ok').code(200); 41 | }, 42 | }); 43 | }; 44 | 45 | EVENTS.forEach(evt => addEvent(evt)); 46 | await server.start(); 47 | return server; 48 | }; 49 | -------------------------------------------------------------------------------- /src/installAddonBench.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { sync as spawnSync } from 'cross-spawn'; 3 | 4 | const ADDONS_REGEX = /(addons.*\:.*\[)/g; 5 | const STDIO = 'inherit'; 6 | 7 | const insertAddonBench = (main: string) => { 8 | const lines = main.split('\n'); 9 | const updated = lines.map(line => line.replace(ADDONS_REGEX, '$1 "@storybook/addon-bench",')); 10 | return updated.join('\n'); 11 | }; 12 | 13 | const findMainJs = () => { 14 | return ['js', 'cjs', 'mjs', 'ts'].map(suffix => `.storybook/main.${suffix}`).find(fname => fs.existsSync(fname)); 15 | }; 16 | 17 | export const installAddonBench = async () => { 18 | let commandArgs = ['add', '@storybook/addon-bench@next', '--dev']; 19 | if (isUsingYarn1()) { 20 | commandArgs.push('-W'); 21 | } 22 | spawnSync('yarn', commandArgs, { 23 | stdio: STDIO, 24 | }); 25 | const mainFile = findMainJs(); 26 | if (!mainFile) throw new Error('No main.js found!'); 27 | const main = fs.readFileSync(mainFile).toString(); 28 | if (!main.includes('@storybook/addon-bench')) { 29 | const mainWithBench = insertAddonBench(main); 30 | fs.writeFileSync(mainFile, mainWithBench); 31 | } 32 | }; 33 | 34 | const isUsingYarn1 = (): boolean => { 35 | const yarnVersionCommand = spawnSync('yarn', ['--version']); 36 | 37 | if (yarnVersionCommand.status !== 0) { 38 | throw new Error(`🧶 Yarn must be installed to run '@storybook/bench'`); 39 | } 40 | 41 | const yarnVersion = yarnVersionCommand.output 42 | .toString() 43 | .replace(/,/g, '') 44 | .replace(/"/g, ''); 45 | 46 | return /^1\.+/.test(yarnVersion); 47 | }; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@storybook/bench", 3 | "version": "0.7.5", 4 | "license": "MIT", 5 | "author": "Michael Shilman", 6 | "main": "dist/index.js", 7 | "module": "dist/sb-bench.esm.js", 8 | "typings": "dist/index.d.ts", 9 | "bin": { 10 | "sb-bench": "./bin/index.js", 11 | "sb-upload": "./bin/upload.js" 12 | }, 13 | "files": [ 14 | "bin", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsdx build --target node --format cjs", 19 | "lint": "tsdx lint", 20 | "prepare": "yarn build", 21 | "release": "yarn build && auto shipit", 22 | "start": "tsdx watch", 23 | "test": "tsdx test" 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 120, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "dependencies": { 37 | "@google-cloud/bigquery": "^5.2.0", 38 | "@hapi/hapi": "^20.2.2", 39 | "@hapi/inert": "^6.0.5", 40 | "@types/fs-extra": "^9.0.13", 41 | "commander": "^5.1.0", 42 | "cross-spawn": "^7.0.3", 43 | "du": "^1.0.0", 44 | "exectimer": "^2.2.2", 45 | "fs-extra": "^10.1.0", 46 | "jsonexport": "^3.0.1", 47 | "lodash": "^4.17.19", 48 | "playwright": "^1.24.2", 49 | "pretty-bytes": "^5.3.0", 50 | "rimraf": "^3.0.2" 51 | }, 52 | "devDependencies": { 53 | "@auto-it/released": "^10.32.6", 54 | "@types/cross-spawn": "^6.0.2", 55 | "@types/hapi__hapi": "^20.0.12", 56 | "@types/hapi__inert": "^5.2.3", 57 | "@types/lodash": "^4.14.157", 58 | "@types/node-fetch": "^2.5.7", 59 | "@types/puppeteer": "^3.0.1", 60 | "auto": "^10.3.0", 61 | "husky": "^4.2.5", 62 | "tsdx": "^0.14.1", 63 | "tslib": "^2.4.0", 64 | "typescript": "^4.7.4" 65 | }, 66 | "engines": { 67 | "node": ">=14" 68 | }, 69 | "publishConfig": { 70 | "access": "public" 71 | }, 72 | "auto": { 73 | "plugins": [ 74 | "npm", 75 | "released" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storybook Bench 2 | 3 | A simple benchmark for Storybook. Usage: 4 | 5 | ``` 6 | npx playwright install 7 | npx @storybook/bench 'npx sb init' 8 | ``` 9 | 10 | This will: 11 | 12 | - Install playwright browsers (unnecessary if you've already installed playwright on your machine) 13 | - Install storybook using `sb init` (or whatever command is provided) 14 | - Measure install time and size 15 | - Start 16 | - Measure build time 17 | - Measure page load time 18 | - Build 19 | - Measure build time 20 | - Browse 21 | - Measure page load time 22 | - Measure bundle sizes 23 | 24 | It outputs all results to the files `bench.csv` and `bench.json`. It uploads results to a BigQuery data warehouse if `SB_BENCH_UPLOAD` and `GCP_CREDENTIALS` environment variables are set. 25 | 26 | ## Flags 27 | 28 | It also accepts the following flags: 29 | 30 | | option | description | 31 | | --------------- | --------------------------------------------------------------------------- | 32 | | --label