├── .eslintrc ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── .npmignore ├── LICENSE ├── README.md ├── lerna.json ├── package.json ├── packages ├── cli │ ├── README.md │ ├── auth-script.js │ ├── auth-script.ts │ ├── bin │ │ └── cli │ ├── cli.gif │ ├── package.json │ ├── perfectum.json │ ├── src │ │ ├── cli.ts │ │ ├── commands │ │ │ ├── audit │ │ │ │ ├── builder.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── handler.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── constants.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── clear-terminal-window.ts │ │ │ ├── constants.ts │ │ │ ├── get-initial-config.ts │ │ │ ├── logging.ts │ │ │ └── show-welcome-message.ts │ └── tsconfig.json ├── client │ ├── .eslintrc.js │ ├── .size-snapshot.json │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── __tests__ │ │ │ ├── fixtures │ │ │ │ └── index.ts │ │ │ ├── mocks │ │ │ │ ├── page-visibility.ts │ │ │ │ ├── performance-observer.ts │ │ │ │ ├── performance.ts │ │ │ │ └── send-beacon.ts │ │ │ └── perfectum-client.test.ts │ │ ├── index.ts │ │ ├── logger │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── monitoring │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── performance │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── tsconfig.eslint.json │ └── tsconfig.json └── synthetic │ ├── README.md │ ├── package.json │ ├── src │ ├── auditor │ │ ├── configs │ │ │ ├── constants.ts │ │ │ ├── desktop.ts │ │ │ ├── index.ts │ │ │ ├── mobile.ts │ │ │ └── types.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils │ │ │ └── create-lighthouse-budget.ts │ ├── authenticator │ │ ├── constants.ts │ │ ├── index.ts │ │ └── types.ts │ ├── browser │ │ ├── index.ts │ │ └── types.ts │ ├── builder │ │ ├── index.ts │ │ └── types.ts │ ├── index.ts │ ├── logger │ │ ├── index.ts │ │ └── types.ts │ ├── reporter │ │ ├── constants.ts │ │ ├── index.ts │ │ └── types.ts │ ├── starter │ │ ├── index.ts │ │ └── types.ts │ ├── types.ts │ └── utils │ │ ├── command-runner.ts │ │ └── config-validators.ts │ └── tsconfig.json ├── perfectum.json ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 8 | ], 9 | "plugins": ["@typescript-eslint"], 10 | "parserOptions": { 11 | "project": "./tsconfig.json", 12 | "sourceType": "module", 13 | "ecmaVersion": 2019 14 | }, 15 | "rules": { 16 | "@typescript-eslint/explicit-function-return-type": "off", 17 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false }] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea 3 | .vscode 4 | .history 5 | 6 | # Logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Dependencies 14 | node_modules 15 | 16 | # Compiled sources 17 | lib 18 | 19 | # TypeScript cache 20 | *.tsbuildinfo 21 | 22 | # Metadata 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | "hooks": { 2 | "pre-commit": "lerna run lint-staged && git add ." 3 | } 4 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.ts": ["yarn run lint-ts"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2020 Tinkoff Bank 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **⛔️ DEPRECATED ⛔️** 2 | 3 | This project is no longer supported, please consider using [perfume.js](https://zizzamia.github.io/perfume/) library instead for client performance monitoring. 4 | 5 | # Perfectum 6 | 7 | A set of tools for working with project performance 8 | 9 | ## Projects 10 | * [Perfectum CLI](./packages/cli) 11 | * [Perfectum Client](./packages/client) 12 | * [Perfectum Synthetic](./packages/synthetic) 13 | 14 | ## Articles 15 | * [Client performance monitoring](https://habr.com/ru/company/tinkoff/blog/508256/) 16 | * [Synthetic performance monitoring](https://habr.com/ru/company/tinkoff/blog/508258/) 17 | 18 | ## Contributing 19 | 1. Clone the Project 20 | 2. Create your Branch (`git checkout -b feature/amazing-feature`) 21 | 3. Commit your Changes (`git commit -m 'feat(*): add some amazing feature'`) 22 | 4. Push to the Branch (`git push origin feature/amazing-feature`) 23 | 5. Open a Pull Request 24 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "useWorkspaces": true, 7 | "version": "independent" 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "perfectum", 3 | "description": "A set of tools for working with project performance", 4 | "author": "Zakharov Vladislav ", 5 | "private": true, 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "devDependencies": { 10 | "@types/jest": "25.2.1", 11 | "@typescript-eslint/eslint-plugin": "2.28.0", 12 | "@typescript-eslint/parser": "2.28.0", 13 | "eslint": "6.8.0", 14 | "husky": "4.2.5", 15 | "jest": "25.3.0", 16 | "lerna": "3.22.0", 17 | "lint-staged": "10.2.6", 18 | "npm-run-all": "4.1.5", 19 | "rimraf": "3.0.2", 20 | "ts-jest": "25.3.1", 21 | "typescript": "3.9.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | # Perfectum CLI 2 | The command-line interface for convenient work with Perfectum performance tools :computer: 3 | ### ![Perfectum CLI](cli.gif) 4 | 5 | ## Installation 6 | ```sh 7 | yarn global add @perfectum/cli 8 | ``` 9 | 10 | ## Usage 11 | ```sh 12 | perfectum 13 | ``` 14 | 15 | ## Commands 16 | ### `audit` 17 | **Description** 18 | 19 | Running a project performance audit using [@perfectum/synthetic](../synthetic) library 20 | 21 | **Example** 22 | ```bash 23 | perfectum audit --urls.main="https://www.example.com" --config="./perfectum.json" 24 | ``` 25 | 26 | **Options** 27 | ```bash 28 | --urls, -u URLs whose performance you want to audit [object] 29 | 30 | --config, -c Path to configuration file [string] 31 | 32 | --numberOfAuditRuns, -n Number of performance audit runs [number] 33 | 34 | --authenticationScriptPath Path to authentication script file [string] 35 | 36 | --commandExecutionContextPath Path to execution context directory [string] 37 | 38 | --skipBuildProject Skip the build phase of the project [boolean] 39 | 40 | --skipStartProject Skip the start phase of the project [boolean] 41 | 42 | --buildProjectCommand Command to build the project [string] 43 | 44 | --startProjectCommand Command to start the project [string] 45 | 46 | --buildProjectTimeout Timeout for the project build command in minutes [number] 47 | 48 | --startProjectTimeout Timeout for the project start command in minutes [number] 49 | 50 | --buildProjectCompleteStringPattern String pattern for listening to the end of the project build [string] 51 | 52 | --startProjectCompleteStringPattern String pattern for listening to the end of the project start [string] 53 | 54 | --clearReportFilesDirectoryBeforeAudit Clear the directory with report files before audit [boolean] 55 | 56 | --version Show version number [boolean] 57 | 58 | --help Show help [boolean] 59 | ``` 60 | 61 | For a more flexible configuration of launching an audit use the [Perfectum configuration file](./perfectum.json#L137). 62 | -------------------------------------------------------------------------------- /packages/cli/auth-script.js: -------------------------------------------------------------------------------- 1 | const USER_LOGIN = 'login'; 2 | const USER_PASSWORD = 'password'; 3 | const INPUT_LOGIN_SELECTOR = 'input[name="login"]'; 4 | const INPUT_PASSWORD_SELECTOR = 'input[name="password"]'; 5 | const SUBMIT_BUTTON_SELECTOR = 'button[type="submit"]'; 6 | const LOGIN_PAGE_URL = 'http://localhost:4100/login'; 7 | 8 | const authenticate = async ({ browser }) => { 9 | const page = await browser.newPage(); 10 | 11 | await page.goto(LOGIN_PAGE_URL); 12 | await page.type(INPUT_LOGIN_SELECTOR, USER_LOGIN); 13 | await page.type(INPUT_PASSWORD_SELECTOR, USER_PASSWORD); 14 | await page.click(SUBMIT_BUTTON_SELECTOR); 15 | await page.close(); 16 | }; 17 | 18 | exports.default = authenticate; 19 | -------------------------------------------------------------------------------- /packages/cli/auth-script.ts: -------------------------------------------------------------------------------- 1 | import { Browser } from 'puppeteer'; 2 | 3 | const USER_LOGIN = 'login'; 4 | const USER_PASSWORD = 'password'; 5 | const INPUT_LOGIN_SELECTOR = 'input[name="login"]'; 6 | const INPUT_PASSWORD_SELECTOR = 'input[name="password"]'; 7 | const SUBMIT_BUTTON_SELECTOR = 'button[type="submit"]'; 8 | const LOGIN_PAGE_URL = 'http://localhost:4100/login'; 9 | 10 | const authenticate = async ({ browser }: { url: string; browser: Browser }) => { 11 | const page = await browser.newPage(); 12 | 13 | await page.goto(LOGIN_PAGE_URL); 14 | await page.type(INPUT_LOGIN_SELECTOR, USER_LOGIN); 15 | await page.type(INPUT_PASSWORD_SELECTOR, USER_PASSWORD); 16 | await page.click(SUBMIT_BUTTON_SELECTOR); 17 | await page.close(); 18 | }; 19 | 20 | export default authenticate; 21 | -------------------------------------------------------------------------------- /packages/cli/bin/cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../lib/index.js') 3 | -------------------------------------------------------------------------------- /packages/cli/cli.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinkoff/perfectum/cd96475146c24e0b137a7cc1e5e922d9fab52e3f/packages/cli/cli.gif -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perfectum/cli", 3 | "version": "1.1.0", 4 | "description": "Command line interface for Perfectum performance tools", 5 | "author": "Zakharov Vladislav ", 6 | "license": "Apache-2.0", 7 | "main": "lib/index.js", 8 | "typings": "lib/typings/index.d.ts", 9 | "scripts": { 10 | "prebuild": "rimraf lib", 11 | "build": "tsc -b", 12 | "lint": "lint-ts", 13 | "lint-staged": "lint-staged", 14 | "lint-ts": "eslint 'src/**/*.ts'" 15 | }, 16 | "bin": { 17 | "perfectum": "bin/cli" 18 | }, 19 | "dependencies": { 20 | "@perfectum/synthetic": "^1.1.0", 21 | "clear": "0.1.0", 22 | "deepmerge": "4.2.2", 23 | "figlet": "1.3.0", 24 | "kleur": "3.0.3", 25 | "node-emoji": "1.10.0", 26 | "yargs": "15.3.1", 27 | "yargs-parser": "18.1.2" 28 | }, 29 | "devDependencies": { 30 | "@types/clear": "0.1.0", 31 | "@types/figlet": "1.2.0", 32 | "@types/node-emoji": "1.8.1", 33 | "@types/yargs": "15.0.4" 34 | }, 35 | "files": [ 36 | "lib" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /packages/cli/perfectum.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../perfectum.json", 3 | "client": { 4 | "urls": { 5 | "main": "http://example.com/", 6 | "profile": "http://example.com/profile/" 7 | }, 8 | "budgets": [ 9 | { 10 | "url": "main", 11 | "largest-contentful-paint": { 12 | "mobile": { 13 | "target": 3150, 14 | "current": 3450 15 | }, 16 | "desktop": { 17 | "target": 2350, 18 | "current": 2750 19 | } 20 | }, 21 | "first-contentful-paint": { 22 | "mobile": { 23 | "target": 1950, 24 | "current": 2250 25 | }, 26 | "desktop": { 27 | "target": 1750, 28 | "current": 2050 29 | } 30 | }, 31 | "first-paint": { 32 | "mobile": { 33 | "target": 1850, 34 | "current": 2150 35 | }, 36 | "desktop": { 37 | "target": 1450, 38 | "current": 1750 39 | } 40 | }, 41 | "first-input-delay": { 42 | "mobile": { 43 | "target": 35, 44 | "current": 50 45 | }, 46 | "desktop": { 47 | "target": 15, 48 | "current": 25 49 | } 50 | }, 51 | "download-document-time": { 52 | "mobile": { 53 | "target": 1050, 54 | "current": 1350 55 | }, 56 | "desktop": { 57 | "target": 750, 58 | "current": 1000 59 | } 60 | }, 61 | "server-response-time": { 62 | "mobile": { 63 | "target": 550, 64 | "current": 700 65 | }, 66 | "desktop": { 67 | "target": 500, 68 | "current": 600 69 | } 70 | } 71 | }, 72 | { 73 | "url": "profile", 74 | "largest-contentful-paint": { 75 | "mobile": { 76 | "target": 3650, 77 | "current": 3950 78 | }, 79 | "desktop": { 80 | "target": 2950, 81 | "current": 3250 82 | } 83 | }, 84 | "first-contentful-paint": { 85 | "mobile": { 86 | "target": 2650, 87 | "current": 2950 88 | }, 89 | "desktop": { 90 | "target": 2350, 91 | "current": 2650 92 | } 93 | }, 94 | "first-paint": { 95 | "mobile": { 96 | "target": 2350, 97 | "current": 2650 98 | }, 99 | "desktop": { 100 | "target": 2150, 101 | "current": 2450 102 | } 103 | }, 104 | "first-input-delay": { 105 | "mobile": { 106 | "target": 50, 107 | "current": 120 108 | }, 109 | "desktop": { 110 | "target": 15, 111 | "current": 15 112 | } 113 | }, 114 | "download-document-time": { 115 | "mobile": { 116 | "target": 2150, 117 | "current": 2450 118 | }, 119 | "desktop": { 120 | "target": 1850, 121 | "current": 2150 122 | } 123 | }, 124 | "server-response-time": { 125 | "mobile": { 126 | "target": 850, 127 | "current": 1050 128 | }, 129 | "desktop": { 130 | "target": 850, 131 | "current": 1050 132 | } 133 | } 134 | } 135 | ] 136 | }, 137 | "synthetic": { 138 | "urls": { 139 | "main": "http://localhost:4100/", 140 | "profile": "http://localhost:4100/profile/" 141 | }, 142 | "numberOfAuditRuns": 3, 143 | "buildProjectTimeout": 5, 144 | "startProjectTimeout": 5, 145 | "buildProjectCommand": "yarn run build", 146 | "startProjectCommand": "yarn run start", 147 | "authenticationScriptPath": "./auth-script.ts", 148 | "buildProjectCompleteStringPattern": "The project was built", 149 | "startProjectCompleteStringPattern": "You can now view example in the browser", 150 | "budgets": [ 151 | { 152 | "url": "main", 153 | "first-contentful-paint": { 154 | "mobile": { 155 | "target": 1650, 156 | "current": 1950 157 | }, 158 | "desktop": { 159 | "target": 1350, 160 | "current": 1650 161 | } 162 | }, 163 | "largest-contentful-paint": { 164 | "mobile": { 165 | "target": 1850, 166 | "current": 2150 167 | }, 168 | "desktop": { 169 | "target": 1550, 170 | "current": 1850 171 | } 172 | }, 173 | "speed-index": { 174 | "mobile": { 175 | "target": 1950, 176 | "current": 2250 177 | }, 178 | "desktop": { 179 | "target": 1650, 180 | "current": 1950 181 | } 182 | }, 183 | "cumulative-layout-shift": { 184 | "mobile": { 185 | "target": 0.3, 186 | "current": 0.8 187 | }, 188 | "desktop": { 189 | "target": 0.3, 190 | "current": 0.6 191 | } 192 | }, 193 | "max-potential-fid": { 194 | "mobile": { 195 | "target": 115, 196 | "current": 200 197 | }, 198 | "desktop": { 199 | "target": 85, 200 | "current": 150 201 | } 202 | }, 203 | "interactive": { 204 | "mobile": { 205 | "target": 2750, 206 | "current": 3050 207 | }, 208 | "desktop": { 209 | "target": 2150, 210 | "current": 2450 211 | } 212 | }, 213 | "total-blocking-time": { 214 | "mobile": { 215 | "target": 120, 216 | "current": 200 217 | }, 218 | "desktop": { 219 | "target": 120, 220 | "current": 200 221 | } 222 | }, 223 | "resource-sizes": { 224 | "script": { 225 | "mobile": { 226 | "target": 1000, 227 | "current": 1235 228 | }, 229 | "desktop": { 230 | "target": 1000, 231 | "current": 1275 232 | } 233 | }, 234 | "stylesheet": { 235 | "mobile": { 236 | "target": 95, 237 | "current": 125 238 | }, 239 | "desktop": { 240 | "target": 85, 241 | "current": 130 242 | } 243 | }, 244 | "document": { 245 | "mobile": { 246 | "target": 90, 247 | "current": 90 248 | }, 249 | "desktop": { 250 | "target": 90, 251 | "current": 90 252 | } 253 | }, 254 | "font": { 255 | "mobile": { 256 | "target": 120, 257 | "current": 160 258 | }, 259 | "desktop": { 260 | "target": 120, 261 | "current": 160 262 | } 263 | }, 264 | "image": { 265 | "mobile": { 266 | "target": 500, 267 | "current": 740 268 | }, 269 | "desktop": { 270 | "target": 300, 271 | "current": 415 272 | } 273 | }, 274 | "media": { 275 | "mobile": { 276 | "target": 0, 277 | "current": 0 278 | }, 279 | "desktop": { 280 | "target": 0, 281 | "current": 0 282 | } 283 | }, 284 | "other": { 285 | "mobile": { 286 | "target": 50, 287 | "current": 50 288 | }, 289 | "desktop": { 290 | "target": 40, 291 | "current": 40 292 | } 293 | }, 294 | "total": { 295 | "mobile": { 296 | "target": 2450, 297 | "current": 2800 298 | }, 299 | "desktop": { 300 | "target": 2150, 301 | "current": 2450 302 | } 303 | } 304 | }, 305 | "resource-requests": { 306 | "script": { 307 | "mobile": { 308 | "target": 20, 309 | "current": 25 310 | }, 311 | "desktop": { 312 | "target": 20, 313 | "current": 25 314 | } 315 | }, 316 | "stylesheet": { 317 | "mobile": { 318 | "target": 5, 319 | "current": 9 320 | }, 321 | "desktop": { 322 | "target": 5, 323 | "current": 9 324 | } 325 | }, 326 | "document": { 327 | "mobile": { 328 | "target": 3, 329 | "current": 3 330 | }, 331 | "desktop": { 332 | "target": 3, 333 | "current": 3 334 | } 335 | }, 336 | "font": { 337 | "mobile": { 338 | "target": 5, 339 | "current": 6 340 | }, 341 | "desktop": { 342 | "target": 5, 343 | "current": 6 344 | } 345 | }, 346 | "image": { 347 | "mobile": { 348 | "target": 30, 349 | "current": 35 350 | }, 351 | "desktop": { 352 | "target": 30, 353 | "current": 35 354 | } 355 | }, 356 | "media": { 357 | "mobile": { 358 | "target": 0, 359 | "current": 0 360 | }, 361 | "desktop": { 362 | "target": 0, 363 | "current": 0 364 | } 365 | }, 366 | "other": { 367 | "mobile": { 368 | "target": 30, 369 | "current": 30 370 | }, 371 | "desktop": { 372 | "target": 35, 373 | "current": 35 374 | } 375 | }, 376 | "total": { 377 | "mobile": { 378 | "target": 90, 379 | "current": 100 380 | }, 381 | "desktop": { 382 | "target": 100, 383 | "current": 110 384 | } 385 | } 386 | } 387 | }, 388 | { 389 | "url": "profile", 390 | "first-contentful-paint": { 391 | "mobile": { 392 | "target": 2650, 393 | "current": 2950 394 | }, 395 | "desktop": { 396 | "target": 2350, 397 | "current": 2650 398 | } 399 | }, 400 | "largest-contentful-paint": { 401 | "mobile": { 402 | "target": 2850, 403 | "current": 3150 404 | }, 405 | "desktop": { 406 | "target": 2550, 407 | "current": 2850 408 | } 409 | }, 410 | "speed-index": { 411 | "mobile": { 412 | "target": 2950, 413 | "current": 3250 414 | }, 415 | "desktop": { 416 | "target": 2650, 417 | "current": 2950 418 | } 419 | }, 420 | "cumulative-layout-shift": { 421 | "mobile": { 422 | "target": 0.3, 423 | "current": 0.8 424 | }, 425 | "desktop": { 426 | "target": 0.3, 427 | "current": 0.6 428 | } 429 | }, 430 | "max-potential-fid": { 431 | "mobile": { 432 | "target": 85, 433 | "current": 150 434 | }, 435 | "desktop": { 436 | "target": 65, 437 | "current": 130 438 | } 439 | }, 440 | "interactive": { 441 | "mobile": { 442 | "target": 3850, 443 | "current": 4150 444 | }, 445 | "desktop": { 446 | "target": 3450, 447 | "current": 3750 448 | } 449 | }, 450 | "total-blocking-time": { 451 | "mobile": { 452 | "target": 100, 453 | "current": 150 454 | }, 455 | "desktop": { 456 | "target": 100, 457 | "current": 150 458 | } 459 | }, 460 | "resource-sizes": { 461 | "script": { 462 | "mobile": { 463 | "target": 1100, 464 | "current": 1300 465 | }, 466 | "desktop": { 467 | "target": 1100, 468 | "current": 1300 469 | } 470 | }, 471 | "stylesheet": { 472 | "mobile": { 473 | "target": 110, 474 | "current": 150 475 | }, 476 | "desktop": { 477 | "target": 110, 478 | "current": 150 479 | } 480 | }, 481 | "document": { 482 | "mobile": { 483 | "target": 300, 484 | "current": 385 485 | }, 486 | "desktop": { 487 | "target": 300, 488 | "current": 385 489 | } 490 | }, 491 | "font": { 492 | "mobile": { 493 | "target": 100, 494 | "current": 115 495 | }, 496 | "desktop": { 497 | "target": 100, 498 | "current": 115 499 | } 500 | }, 501 | "image": { 502 | "mobile": { 503 | "target": 50, 504 | "current": 65 505 | }, 506 | "desktop": { 507 | "target": 60, 508 | "current": 65 509 | } 510 | }, 511 | "media": { 512 | "mobile": { 513 | "target": 0, 514 | "current": 0 515 | }, 516 | "desktop": { 517 | "target": 0, 518 | "current": 0 519 | } 520 | }, 521 | "other": { 522 | "mobile": { 523 | "target": 15, 524 | "current": 15 525 | }, 526 | "desktop": { 527 | "target": 15, 528 | "current": 15 529 | } 530 | }, 531 | "total": { 532 | "mobile": { 533 | "target": 1650, 534 | "current": 2000 535 | }, 536 | "desktop": { 537 | "target": 1750, 538 | "current": 2000 539 | } 540 | } 541 | }, 542 | "resource-requests": { 543 | "script": { 544 | "mobile": { 545 | "target": 20, 546 | "current": 25 547 | }, 548 | "desktop": { 549 | "target": 20, 550 | "current": 25 551 | } 552 | }, 553 | "stylesheet": { 554 | "mobile": { 555 | "target": 5, 556 | "current": 7 557 | }, 558 | "desktop": { 559 | "target": 5, 560 | "current": 7 561 | } 562 | }, 563 | "document": { 564 | "mobile": { 565 | "target": 3, 566 | "current": 3 567 | }, 568 | "desktop": { 569 | "target": 3, 570 | "current": 3 571 | } 572 | }, 573 | "font": { 574 | "mobile": { 575 | "target": 5, 576 | "current": 5 577 | }, 578 | "desktop": { 579 | "target": 5, 580 | "current": 5 581 | } 582 | }, 583 | "image": { 584 | "mobile": { 585 | "target": 25, 586 | "current": 30 587 | }, 588 | "desktop": { 589 | "target": 25, 590 | "current": 30 591 | } 592 | }, 593 | "media": { 594 | "mobile": { 595 | "target": 0, 596 | "current": 0 597 | }, 598 | "desktop": { 599 | "target": 0, 600 | "current": 0 601 | } 602 | }, 603 | "other": { 604 | "mobile": { 605 | "target": 25, 606 | "current": 25 607 | }, 608 | "desktop": { 609 | "target": 30, 610 | "current": 30 611 | } 612 | }, 613 | "total": { 614 | "mobile": { 615 | "target": 80, 616 | "current": 90 617 | }, 618 | "desktop": { 619 | "target": 80, 620 | "current": 90 621 | } 622 | } 623 | } 624 | } 625 | ], 626 | "auditConfig": { 627 | "mobile": { 628 | "settings": { 629 | "throttling": { 630 | "rttMs": 150, 631 | "throughputKbps": 51200, 632 | "cpuSlowdownMultiplier": 1 633 | } 634 | } 635 | }, 636 | "desktop": { 637 | "settings": { 638 | "throttling": { 639 | "rttMs": 100, 640 | "throughputKbps": 71680, 641 | "cpuSlowdownMultiplier": 1 642 | } 643 | } 644 | } 645 | }, 646 | "browserConfig": { 647 | "logLevel": "silent" 648 | }, 649 | "reporterConfig": { 650 | "reportPrefixName": "performance-report", 651 | "reportOutputPath": "./.performance-reports", 652 | "reportFormats": ["html"] 653 | } 654 | } 655 | } 656 | -------------------------------------------------------------------------------- /packages/cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | import { commandsStore } from './commands'; 3 | import { CommandsNames } from './commands/constants'; 4 | import { getInitialConfigFromFile } from './utils/get-initial-config'; 5 | 6 | export const runCommandLineInterface = () => { 7 | yargs 8 | .help('help') 9 | .usage('perfectum ') 10 | .demand(1) 11 | .command( 12 | commandsStore[CommandsNames.audit].cmd, 13 | commandsStore[CommandsNames.audit].desc, 14 | commandsStore[CommandsNames.audit].builder, 15 | commandsStore[CommandsNames.audit].handler // eslint-disable-line @typescript-eslint/no-misused-promises 16 | ) 17 | .config(getInitialConfigFromFile()) 18 | .argv 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/commands/audit/builder.ts: -------------------------------------------------------------------------------- 1 | import { Argv } from 'yargs'; 2 | 3 | export const auditCommandBuilder = (yargs: Argv) => { 4 | return yargs 5 | .options('urls', { 6 | alias: 'u', 7 | describe: 'The set of URLs (e.g. individual application pages) whose performance you want to audit' 8 | }) 9 | .options('config', { 10 | alias: 'c', 11 | type: 'string', 12 | describe: 'Path to configuration file' 13 | }) 14 | .options('numberOfAuditRuns', { 15 | alias: 'n', 16 | type: 'number', 17 | describe: 'Number of performance audit runs' 18 | }) 19 | .options('authenticationScriptPath', { 20 | type: 'string', 21 | describe: 'Path to authentication script file' 22 | }) 23 | .options('commandExecutionContextPath', { 24 | type: 'string', 25 | describe: 'Path to execution context directory' 26 | }) 27 | .options('skipBuildProject', { 28 | type: 'boolean', 29 | describe: 'Skip the build phase of the project' 30 | }) 31 | .options('skipStartProject', { 32 | type: 'boolean', 33 | describe: 'Skip the start phase of the project' 34 | }) 35 | .options('startProjectCommand', { 36 | type: 'string', 37 | describe: 'Command to start the project' 38 | }) 39 | .options('buildProjectCommand', { 40 | type: 'string', 41 | describe: 'Command to build the project' 42 | }) 43 | .options('startProjectTimeout', { 44 | type: 'number', 45 | describe: 'Timeout for the project start command in minutes' 46 | }) 47 | .options('buildProjectTimeout', { 48 | type: 'number', 49 | describe: 'Timeout for the project build command in minutes' 50 | }) 51 | .options('startProjectCompleteStringPattern', { 52 | type: 'string', 53 | describe: 'String pattern for listening to the end of the project start' 54 | }) 55 | .options('buildProjectCompleteStringPattern', { 56 | type: 'string', 57 | describe: 'String pattern for listening to the end of the project build' 58 | }) 59 | .options('clearReportFilesDirectoryBeforeAudit', { 60 | type: 'boolean', 61 | describe: 'Clear the directory with report files before audit' 62 | }) 63 | }; 64 | -------------------------------------------------------------------------------- /packages/cli/src/commands/audit/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUDIT_COMMAND = { 2 | command: 'audit', 3 | description: 'Run a project performance audit' 4 | } 5 | -------------------------------------------------------------------------------- /packages/cli/src/commands/audit/handler.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import emoji from 'node-emoji'; 3 | import deepmerge from 'deepmerge'; 4 | import SyntheticPerformance, { SyntheticPerformanceConfig } from '@perfectum/synthetic'; 5 | import { printError } from '../../utils/logging'; 6 | import { ParsedArgv } from './types'; 7 | 8 | export const auditCommandHandler = async (argv: ParsedArgv) => { 9 | try { 10 | const resolvedAuthenticationScriptPath = resolveAuthenticationScriptPath(argv); 11 | const resolvedCommandExecutionContextPath = resolveCommandExecutionContextPath(argv); 12 | 13 | const syntheticPerformanceConfigFromFile = getSyntheticPerformanceConfigFromFile(argv); 14 | 15 | // Remove unnecessary properties added during initialization of the config (get-initial-config.ts) 16 | delete argv.configFile; 17 | delete argv.configFilePath; 18 | 19 | const syntheticPerformanceConfigFromCli = argv; 20 | 21 | const syntheticPerformanceConfig = deepmerge( 22 | syntheticPerformanceConfigFromFile || {}, 23 | syntheticPerformanceConfigFromCli, 24 | ) as SyntheticPerformanceConfig; 25 | 26 | syntheticPerformanceConfig.authenticationScriptPath = resolvedAuthenticationScriptPath; 27 | syntheticPerformanceConfig.commandExecutionContextPath = resolvedCommandExecutionContextPath; 28 | 29 | await runSyntheticPerformanceAudit(syntheticPerformanceConfig); 30 | 31 | process.exit(0); 32 | } catch (error) { 33 | printError(`\n${emoji.get('rotating_light')} Audit command failed\n\n${error}\n`); 34 | 35 | process.exit(1); 36 | } 37 | }; 38 | 39 | function resolveAuthenticationScriptPath(argv: ParsedArgv) { 40 | if (!argv.configFilePath) { 41 | return; 42 | } 43 | 44 | const configFileDirectoryPath = path.dirname(argv.configFilePath); 45 | const authenticationScriptPathFromCli = argv.authenticationScriptPath; 46 | const authenticationScriptPathFromConfigFile = argv?.configFile?.synthetic?.authenticationScriptPath; 47 | 48 | if (authenticationScriptPathFromCli) { 49 | return path.resolve(path.join( 50 | configFileDirectoryPath, 51 | authenticationScriptPathFromCli 52 | )); 53 | } 54 | 55 | if (authenticationScriptPathFromConfigFile) { 56 | return path.resolve(path.join( 57 | configFileDirectoryPath, 58 | authenticationScriptPathFromConfigFile 59 | )); 60 | } 61 | } 62 | 63 | function resolveCommandExecutionContextPath(argv: ParsedArgv) { 64 | if (!argv.configFilePath) { 65 | return; 66 | } 67 | 68 | const configFileDirectoryPath = path.dirname(argv.configFilePath); 69 | const commandExecutionContextPathFromCli = argv.commandExecutionContextPath; 70 | const commandExecutionContextPathFromConfigFile = argv?.configFile?.synthetic?.commandExecutionContextPath; 71 | 72 | if (commandExecutionContextPathFromCli) { 73 | return path.resolve(path.join( 74 | configFileDirectoryPath, 75 | commandExecutionContextPathFromCli 76 | )); 77 | } 78 | 79 | if (commandExecutionContextPathFromConfigFile) { 80 | return path.resolve(path.join( 81 | configFileDirectoryPath, 82 | commandExecutionContextPathFromConfigFile 83 | )); 84 | } 85 | 86 | const defaultCommandExecutionContextPath = configFileDirectoryPath; 87 | 88 | return defaultCommandExecutionContextPath; 89 | } 90 | 91 | function getSyntheticPerformanceConfigFromFile(argv: ParsedArgv) { 92 | if (argv?.configFile?.synthetic) { 93 | const syntheticPerformanceConfigFromFile = argv.configFile.synthetic; 94 | 95 | return syntheticPerformanceConfigFromFile; 96 | } 97 | } 98 | 99 | async function runSyntheticPerformanceAudit(config: SyntheticPerformanceConfig) { 100 | await new SyntheticPerformance(config).run(); 101 | } 102 | -------------------------------------------------------------------------------- /packages/cli/src/commands/audit/index.ts: -------------------------------------------------------------------------------- 1 | import { auditCommandBuilder } from './builder'; 2 | import { auditCommandHandler } from './handler'; 3 | import { AUDIT_COMMAND } from './constants'; 4 | 5 | export const auditCommandStore= { 6 | cmd: AUDIT_COMMAND.command, 7 | desc: AUDIT_COMMAND.description, 8 | builder: auditCommandBuilder, 9 | handler: auditCommandHandler 10 | }; 11 | -------------------------------------------------------------------------------- /packages/cli/src/commands/audit/types.ts: -------------------------------------------------------------------------------- 1 | import { auditCommandBuilder } from './builder'; 2 | import { InitialConfig } from '../../utils/get-initial-config'; 3 | 4 | export type ParsedArgv = ReturnType['argv'] & InitialConfig; 5 | -------------------------------------------------------------------------------- /packages/cli/src/commands/constants.ts: -------------------------------------------------------------------------------- 1 | export enum CommandsNames { 2 | audit = 'audit' 3 | } 4 | -------------------------------------------------------------------------------- /packages/cli/src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { CommandsNames } from './constants'; 2 | import { auditCommandStore } from './audit'; 3 | 4 | export const commandsStore = { 5 | [CommandsNames.audit]: auditCommandStore 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | import emoji from 'node-emoji'; 2 | import { printError } from './utils/logging'; 3 | import { showWelcomeMessage } from './utils/show-welcome-message'; 4 | import { clearTerminalWindow } from './utils/clear-terminal-window'; 5 | import { runCommandLineInterface } from './cli'; 6 | 7 | try { 8 | clearTerminalWindow(); 9 | showWelcomeMessage(); 10 | runCommandLineInterface(); 11 | } catch (error) { 12 | printError(`\n${emoji.get('rotating_light')} ${error}\n`); 13 | 14 | process.exit(1); 15 | } 16 | -------------------------------------------------------------------------------- /packages/cli/src/utils/clear-terminal-window.ts: -------------------------------------------------------------------------------- 1 | import clear from 'clear'; 2 | 3 | export const clearTerminalWindow = () => clear(); 4 | -------------------------------------------------------------------------------- /packages/cli/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const CLI_WELCOME_MESSAGE = 'Perfectum CLI'; 2 | export const DEFAULT_CONFIG_FILE_NAME = 'perfectum.json'; 3 | -------------------------------------------------------------------------------- /packages/cli/src/utils/get-initial-config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import deepmerge from 'deepmerge'; 4 | import yargsParser from 'yargs-parser'; 5 | import { SyntheticPerformanceConfig } from '@perfectum/synthetic'; 6 | import { DEFAULT_CONFIG_FILE_NAME } from './constants'; 7 | 8 | export const getInitialConfigFromFile = () => { 9 | const configFilePath = getConfigFilePath(); 10 | const configFile = readConfigFile(configFilePath); 11 | 12 | const initialConfig: InitialConfig = { 13 | configFile, 14 | configFilePath 15 | }; 16 | 17 | return initialConfig; 18 | } 19 | 20 | function getConfigFilePath() { 21 | const defaultConfigFilePath = getDefaultConfigFilePath(); 22 | const configFilePathFromCliArgument = getConfigFilePathFromCliArgument(); 23 | 24 | return configFilePathFromCliArgument || defaultConfigFilePath; 25 | } 26 | 27 | function getDefaultConfigFilePath() { 28 | const defaultConfigFilePath = path.resolve(DEFAULT_CONFIG_FILE_NAME); 29 | 30 | if (!isConfigFilePathExists(defaultConfigFilePath)) { 31 | return; 32 | } 33 | 34 | return defaultConfigFilePath; 35 | } 36 | 37 | function getConfigFilePathFromCliArgument() { 38 | const parsedArgv = yargsParser(process.argv.slice(2)) as ParsedArgv; 39 | 40 | if (!parsedArgv.config) { 41 | return; 42 | } 43 | 44 | const configFilePathFromCliArgument = path.resolve(parsedArgv.config); 45 | 46 | if (!isConfigFilePathExists(configFilePathFromCliArgument)) { 47 | throw new Error(`Config file path ${parsedArgv.config} does not exist`); 48 | } 49 | 50 | return configFilePathFromCliArgument; 51 | } 52 | 53 | function readConfigFile(configFilePath?: string) { 54 | if (!configFilePath) { 55 | return; 56 | } 57 | 58 | let config = JSON.parse(fs.readFileSync(configFilePath, 'utf8')) as ConfigFile; 59 | 60 | if (config.extends) { 61 | const currentConfigFileDirectoryPath = path.dirname(configFilePath); 62 | const parentConfigFilePath = path.resolve(path.join(currentConfigFileDirectoryPath, config.extends)); 63 | 64 | if (!isConfigFilePathExists(parentConfigFilePath)) { 65 | throw new Error(`Base config file path ${config.extends} does not exist`); 66 | } 67 | 68 | // Удаляем свойство extends из конфига, для того чтобы yargs 69 | // не использовал его для резолва parent-конфигурации самостоятельно 70 | delete config.extends; 71 | 72 | const parentConfig = readConfigFile(parentConfigFilePath); 73 | 74 | if (parentConfig) { 75 | config = deepmerge(parentConfig, config); 76 | } 77 | } 78 | 79 | return config; 80 | } 81 | 82 | function isConfigFilePathExists(configFilePath: string) { 83 | return fs.existsSync(configFilePath); 84 | } 85 | 86 | type ParsedArgv = yargsParser.Arguments & { 87 | config?: string; 88 | } 89 | 90 | export type ConfigFile = { 91 | extends?: string; 92 | synthetic?: SyntheticPerformanceConfig; 93 | } 94 | 95 | export type InitialConfig = { 96 | configFile?: ConfigFile; 97 | configFilePath?: string; 98 | } 99 | -------------------------------------------------------------------------------- /packages/cli/src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | import { green, yellow, red } from 'kleur'; 2 | 3 | export const print = (message: string) => console.log(green(message)); 4 | export const printError = (message: string) => console.log(red(message)); 5 | export const printWarning = (message: string) => console.log(yellow(message)); 6 | -------------------------------------------------------------------------------- /packages/cli/src/utils/show-welcome-message.ts: -------------------------------------------------------------------------------- 1 | import figlet from 'figlet'; 2 | import { yellow } from 'kleur'; 3 | import { CLI_WELCOME_MESSAGE } from './constants'; 4 | 5 | export const showWelcomeMessage = () => console.log( 6 | yellow(figlet.textSync(CLI_WELCOME_MESSAGE, { horizontalLayout: 'full' })) 7 | ); 8 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "incremental": true, 6 | "module": "commonjs", 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./lib/typings", 10 | "tsBuildInfoFile": "./lib/tsconfig.tsbuildinfo" 11 | }, 12 | "exclude": [ 13 | "lib", 14 | "**/*.test.ts", 15 | "auth-script.ts" 16 | ], 17 | "references": [ 18 | { 19 | "path": "../synthetic" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | project: './tsconfig.eslint.json', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/client/.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib/index.cjs.min.js": { 3 | "bundled": 24714, 4 | "minified": 13002, 5 | "gzipped": 3040 6 | }, 7 | "lib/index.iife.min.js": { 8 | "bundled": 26828, 9 | "minified": 10788, 10 | "gzipped": 2775 11 | }, 12 | "lib/index.es.min.js": { 13 | "bundled": 24697, 14 | "minified": 12989, 15 | "gzipped": 3033, 16 | "treeshaked": { 17 | "rollup": { 18 | "code": 8217, 19 | "import_statements": 0 20 | }, 21 | "webpack": { 22 | "code": 11677 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 | # Perfectum Client 2 | Library for measuring client performance metrics :rocket: 3 | 4 | ## Features 5 | * Using the latest APIs for better performance measurement :dart: 6 | * Measure only user-centric performance metrics :man: 7 | * Minimal impact on application performance :zap: 8 | * Small library size (< 3Kb gzip) :fire: 9 | 10 | ## Built With 11 | * [Beacon API](https://developer.mozilla.org/en-US/docs/Web/API/Beacon_API) 12 | * [Performance API](https://developer.mozilla.org/en-US/docs/Web/API/Performance_API) 13 | * [PerformanceObserver API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver) 14 | 15 | ## Collected Metrics 16 | * [First Paint Time](https://w3c.github.io/paint-timing/) 17 | * [First Contentful Paint Time](https://w3c.github.io/paint-timing/) 18 | * [Largest Contentful Paint Time](https://wicg.github.io/largest-contentful-paint/) 19 | * [Cumulative Layout Shift](https://wicg.github.io/layout-instability/) 20 | * [First Input Delay Duration](https://wicg.github.io/event-timing/) 21 | * [First Input Delay Start Time](https://wicg.github.io/event-timing/) 22 | * [First Input Delay Event Name](https://wicg.github.io/event-timing/) 23 | * [Total Long Tasks](https://w3c.github.io/longtasks/) 24 | * [First Long Task Start Time](https://w3c.github.io/longtasks/) 25 | * [First Long Task Duration](https://w3c.github.io/longtasks/) 26 | * [Domain Lookup Time](https://w3c.github.io/navigation-timing/) 27 | * [Server Connection Time](https://w3c.github.io/navigation-timing/) 28 | * [Server Response Time](https://w3c.github.io/navigation-timing/) 29 | * [Download Document Time](https://w3c.github.io/navigation-timing/) 30 | * [Network Information](https://wicg.github.io/netinfo/) 31 | * [Device Information](https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent/) 32 | * [Custom Metrics](https://wicg.github.io/element-timing/) 33 | 34 | ## Installation 35 | ```sh 36 | yarn add @perfectum/client 37 | ``` 38 | 39 | ## Usage 40 | ```javascript 41 | import Perfectum from '@perfectum/client'; 42 | 43 | Perfectum.init({ 44 | sendMetricsUrl: 'http://example.com/metrics', 45 | sendMetricsData: { 46 | app: 'example', 47 | env: 'production' 48 | }, 49 | sendMetricsCallback: (metrics) => { 50 | const data = JSON.stringify(metrics); 51 | 52 | window.navigator.sendBeacon('http://example.com/metrics', data); 53 | }, 54 | maxPaintTime: 15000 55 | }); 56 | ``` 57 | 58 | By default, before the user closes the page (unload event), the Perfectum will send an object with the collected metrics to the address specified in the ***sendMetricsUrl*** property. 59 | 60 | If you need to add data to the resulting object with collected metrics, for example, the name of the application or the type of environment, you can specify the object with additional data in the ***sendMetricsData*** property. 61 | 62 | If you want to implement your own logic for sending the collected metrics, you can specify a callback in the ***sendMetricsCallback*** property that will be called before the user closes the page (unload event). When calling a callback, an [object](./src/performance/types.ts#L25) with collected metrics will be passed as an argument. 63 | 64 | If you want to filter paint performance metrics such as First Paint, First Contentful Paint, Largest Contentful Paint, you can set the ***maxPaintTime*** property(in milliseconds). By default, it is set to 60 seconds. 65 | 66 | ## Custom Metrics 67 | Custom metrics are the ability to measure the performance of individual elements on a page or the operations performed in your project. These metrics are necessary to provide the most accurate picture of how users perceive the performance of your application. There are two approaches to measuring custom metrics: 68 | 69 | **Measurement at the initialization stage of the application** 70 | 71 | At this stage, we may need to measure the time of appearance of the most important page elements on the user's screen, such as a hero image, cta button, lead form etc. For this type of measurement, you need to add the ***elementtiming*** attribute to the HTML element whose performance or time of appearance on the page you would like to measure. 72 | 73 | ```javascript 74 |

Example App

75 | ``` 76 | 77 | **Measurement at the stage of interaction with the application** 78 | 79 | At this stage, we may need to measure the performance of the priority task, the time spent on an important request, or the rendering of specific page components. For this type of measurement, you need to use the special interface provided in the form of two static methods, ***startMeasure*** and ***stopMeasure***. 80 | 81 | ```javascript 82 | import Perfectum from '@perfectum/client'; 83 | 84 | Perfectum.startMeasure('metric-name'); 85 | 86 | someKindOfImportantTask(); 87 | 88 | Perfectum.stopMeasure('metric-name'); 89 | ``` 90 | -------------------------------------------------------------------------------- /packages/client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testPathIgnorePatterns: ['/mocks/', '/fixtures/'] 4 | }; 5 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perfectum/client", 3 | "version": "1.3.0", 4 | "description": "Library for measuring client performance metrics", 5 | "author": "Zakharov Vladislav ", 6 | "license": "Apache-2.0", 7 | "main": "lib/index.cjs.min.js", 8 | "iife": "lib/index.iife.min.js", 9 | "module": "lib/index.es.min.js", 10 | "typings": "lib/typings/index.d.ts", 11 | "scripts": { 12 | "prebuild": "rimraf lib", 13 | "build": "tsc && rollup --config", 14 | "postbuild": "rimraf lib/transpiled", 15 | "test": "jest", 16 | "lint": "lint-ts", 17 | "lint-staged": "lint-staged", 18 | "lint-ts": "eslint 'src/**/*.ts'" 19 | }, 20 | "devDependencies": { 21 | "rollup": "1.31.1", 22 | "rollup-plugin-node-resolve": "5.2.0", 23 | "rollup-plugin-size-snapshot": "0.11.0", 24 | "rollup-plugin-sourcemaps": "0.5.0", 25 | "rollup-plugin-terser": "5.2.0" 26 | }, 27 | "files": [ 28 | "lib" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/client/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import sourcemaps from 'rollup-plugin-sourcemaps'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | import { sizeSnapshot } from 'rollup-plugin-size-snapshot'; 5 | import packageJsonConfig from './package.json'; 6 | 7 | export default { 8 | input: 'lib/transpiled/index.js', 9 | plugins: [ 10 | resolve(), 11 | sourcemaps(), 12 | sizeSnapshot({ printInfo: false }), 13 | terser() 14 | ], 15 | output: [ 16 | { file: packageJsonConfig.main, name: 'PerfectumClient', format: 'cjs', sourcemap: true }, 17 | { file: packageJsonConfig.iife, name: 'PerfectumClient', format: 'iife', sourcemap: true }, 18 | { file: packageJsonConfig.module, name: 'PerfectumClient', format: 'es', sourcemap: true } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/client/src/__tests__/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Metrics, 3 | EntryTypes, 4 | DeviceTypes, 5 | MetricsStore, 6 | DeviceInformationMetricNames, 7 | } from '../../performance/types'; 8 | 9 | export const METRICS = { 10 | [Metrics.longTasks]: null, 11 | [Metrics.firstPaint]: null, 12 | [Metrics.customMetrics]: null, 13 | [Metrics.firstInputDelay]: null, 14 | [Metrics.navigationTimings]: null, 15 | [Metrics.deviceInformation]: { 16 | [DeviceInformationMetricNames.type]: DeviceTypes.desktop 17 | }, 18 | [Metrics.networkInformation]: null, 19 | [Metrics.firstContentfulPaint]: null, 20 | [Metrics.cumulativeLayoutShift]: null, 21 | [Metrics.largestContentfulPaint]: null 22 | }; 23 | 24 | export const SEND_METRICS_URL = 'http://example.com/performance-metrics'; 25 | 26 | export const SEND_METRICS_DATA = { 27 | app: 'example', 28 | env: 'production' 29 | }; 30 | 31 | export const SEND_METRICS_CALLBACK = (metrics: MetricsStore) => { 32 | const data = JSON.stringify(metrics); 33 | 34 | window.navigator.sendBeacon(SEND_METRICS_URL, data); 35 | }; 36 | 37 | export const MEASURE_ENTRY = { 38 | duration: 100, 39 | entryType: EntryTypes.measure 40 | }; 41 | -------------------------------------------------------------------------------- /packages/client/src/__tests__/mocks/page-visibility.ts: -------------------------------------------------------------------------------- 1 | export const mockPageVisibility = () => { 2 | Object.defineProperty(window.document, 'hidden', { 3 | writable: true, 4 | enumerable: true, 5 | configurable: true, 6 | value: false 7 | }); 8 | 9 | Object.defineProperty(window.document, "visibilityState", { 10 | writable: true, 11 | enumerable: true, 12 | configurable: true, 13 | value: "visible" 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /packages/client/src/__tests__/mocks/performance-observer.ts: -------------------------------------------------------------------------------- 1 | export const mockPerformanceObserver = () => { 2 | Object.defineProperty(window, 'PerformanceObserver', { 3 | writable: true, 4 | enumerable: true, 5 | configurable: true, 6 | value: { 7 | supportedEntryTypes: ['paint'], 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/client/src/__tests__/mocks/performance.ts: -------------------------------------------------------------------------------- 1 | import { MEASURE_ENTRY } from "../fixtures"; 2 | 3 | export const mockPerformance = () => { 4 | Object.defineProperty(window, 'performance', { 5 | writable: true, 6 | enumerable: true, 7 | configurable: true, 8 | value: { 9 | mark: () => 'mark', 10 | measure: () => 'measure', 11 | getEntriesByName: () => [MEASURE_ENTRY] 12 | } 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /packages/client/src/__tests__/mocks/send-beacon.ts: -------------------------------------------------------------------------------- 1 | export const mockSendBeacon = () => { 2 | Object.defineProperty(navigator, 'sendBeacon', { 3 | writable: true, 4 | enumerable: true, 5 | configurable: true, 6 | value: () => { 7 | return; 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/client/src/__tests__/perfectum-client.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import PerfectumClient from '../index'; 3 | import { mockSendBeacon } from './mocks/send-beacon'; 4 | import { mockPerformance } from './mocks/performance'; 5 | import { mockPageVisibility } from './mocks/page-visibility'; 6 | import { mockPerformanceObserver } from './mocks/performance-observer'; 7 | import { 8 | METRICS, 9 | SEND_METRICS_URL, 10 | SEND_METRICS_DATA, 11 | SEND_METRICS_CALLBACK 12 | } from './fixtures'; 13 | 14 | describe('PerfectumClient', () => { 15 | beforeEach(() => { 16 | mockSendBeacon(); 17 | mockPerformance(); 18 | mockPageVisibility(); 19 | mockPerformanceObserver(); 20 | }); 21 | 22 | afterEach(() => { 23 | jest.resetAllMocks(); 24 | jest.resetModules(); 25 | // https://github.com/facebook/jest/issues/3236 26 | // https://github.com/facebook/jest/issues/9430 27 | (PerfectumClient as any).isInitialized = undefined; 28 | (PerfectumClient as any).isSupportedEnvironment = undefined; 29 | }); 30 | 31 | describe('.init()', () => { 32 | it('should be initialized without config', () => { 33 | PerfectumClient.init(); 34 | 35 | expect((PerfectumClient as any).isInitialized).toEqual(true); 36 | }); 37 | 38 | it('should be initialized with empty config', () => { 39 | const config = {}; 40 | 41 | PerfectumClient.init(config); 42 | 43 | expect((PerfectumClient as any).isInitialized).toEqual(true); 44 | }); 45 | 46 | it('should be initialized with config 1', () => { 47 | const config = { 48 | sendMetricsUrl: SEND_METRICS_URL 49 | }; 50 | 51 | const DATA = JSON.stringify({ 52 | ...METRICS 53 | }); 54 | 55 | const sendBeacon = jest.spyOn(window.navigator, 'sendBeacon'); 56 | 57 | PerfectumClient.init(config); 58 | 59 | (window.document as any).visibilityState = 'hidden'; 60 | window.dispatchEvent(new Event('visibilitychange')); 61 | 62 | expect((PerfectumClient as any).isInitialized).toEqual(true); 63 | expect(sendBeacon).toHaveBeenCalledWith(SEND_METRICS_URL, DATA); 64 | }); 65 | 66 | it('should be initialized with config 2', () => { 67 | const config = { 68 | sendMetricsUrl: SEND_METRICS_URL, 69 | sendMetricsData: SEND_METRICS_DATA 70 | }; 71 | 72 | const DATA = JSON.stringify({ 73 | ...METRICS, 74 | ...SEND_METRICS_DATA 75 | }); 76 | 77 | const sendBeacon = jest.spyOn(window.navigator, 'sendBeacon'); 78 | 79 | PerfectumClient.init(config); 80 | 81 | (window.document as any).visibilityState = 'hidden'; 82 | window.dispatchEvent(new Event('visibilitychange')); 83 | expect((PerfectumClient as any).isInitialized).toEqual(true); 84 | expect(sendBeacon).toHaveBeenCalledWith(SEND_METRICS_URL, DATA); 85 | }); 86 | 87 | it('should be initialized with config 3', () => { 88 | const config = { 89 | logging: false, 90 | sendMetricsUrl: SEND_METRICS_URL, 91 | sendMetricsData: SEND_METRICS_DATA, 92 | sendMetricsCallback: SEND_METRICS_CALLBACK 93 | }; 94 | 95 | PerfectumClient.init(config); 96 | 97 | expect((PerfectumClient as any).isInitialized).toEqual(true); 98 | }); 99 | 100 | it('should not be initialized in an unsupported environment 1', () => { 101 | delete (window as any).PerformanceObserver; 102 | 103 | PerfectumClient.init({}); 104 | 105 | expect((PerfectumClient as any).isInitialized).toEqual(undefined); 106 | expect((PerfectumClient as any).isSupportedEnvironment).toEqual(undefined); 107 | }); 108 | 109 | it('should not be initialized in an unsupported environment 2', () => { 110 | delete (window.PerformanceObserver as any).supportedEntryTypes; 111 | 112 | PerfectumClient.init({}); 113 | 114 | expect((PerfectumClient as any).isInitialized).toEqual(undefined); 115 | expect((PerfectumClient as any).isSupportedEnvironment).toEqual(undefined); 116 | }); 117 | 118 | it('should not be initialized in an unsupported environment 3', () => { 119 | (window.document as any).hidden = undefined; 120 | 121 | PerfectumClient.init({}); 122 | 123 | expect((PerfectumClient as any).isInitialized).toEqual(undefined); 124 | expect((PerfectumClient as any).isSupportedEnvironment).toEqual(undefined); 125 | }); 126 | 127 | it('should not be initialized in an unsupported environment 4', () => { 128 | (window.document as any).hidden = true; 129 | 130 | PerfectumClient.init({}); 131 | 132 | expect((PerfectumClient as any).isInitialized).toEqual(undefined); 133 | expect((PerfectumClient as any).isSupportedEnvironment).toEqual(undefined); 134 | }); 135 | 136 | it('should not be initialized in an unsupported environment 5', () => { 137 | delete (window.navigator as any).sendBeacon; 138 | 139 | PerfectumClient.init({}); 140 | 141 | expect((PerfectumClient as any).isInitialized).toEqual(undefined); 142 | expect((PerfectumClient as any).isSupportedEnvironment).toEqual(undefined); 143 | }); 144 | }); 145 | 146 | describe('.startMeasure()', () => { 147 | it('should be called', () => { 148 | const spy = jest.spyOn(window.performance, 'mark'); 149 | 150 | PerfectumClient.init(); 151 | PerfectumClient.startMeasure('metric-name'); 152 | 153 | expect(spy).toHaveBeenCalledTimes(1); 154 | expect(spy).toHaveBeenCalledWith('metric-name:start'); 155 | }); 156 | 157 | it('should be called once', () => { 158 | const spy = jest.spyOn(window.performance, 'mark'); 159 | 160 | PerfectumClient.init(); 161 | PerfectumClient.startMeasure('metric-name'); 162 | PerfectumClient.startMeasure('metric-name'); 163 | PerfectumClient.startMeasure('metric-name'); 164 | 165 | expect(spy).toHaveBeenCalledTimes(1); 166 | expect(spy).toHaveBeenCalledWith('metric-name:start'); 167 | }); 168 | 169 | it('should not be called without calling .init()', () => { 170 | const spy = jest.spyOn(window.performance, 'mark'); 171 | 172 | PerfectumClient.startMeasure('metric-name'); 173 | 174 | expect(spy).toHaveBeenCalledTimes(0); 175 | }); 176 | }); 177 | 178 | describe('.stopMeasure()', () => { 179 | it('should be called', () => { 180 | const spyMark = jest.spyOn(window.performance, 'mark'); 181 | const spyMeasure = jest.spyOn(window.performance, 'measure'); 182 | 183 | PerfectumClient.init(); 184 | PerfectumClient.startMeasure('metric-name'); 185 | PerfectumClient.stopMeasure('metric-name'); 186 | 187 | expect(spyMark).toHaveBeenCalledTimes(2); 188 | expect(spyMeasure).toHaveBeenCalledTimes(1); 189 | expect(spyMark).toHaveBeenCalledWith('metric-name:end'); 190 | expect(spyMeasure).toHaveBeenCalledWith('metric-name', 'metric-name:start', 'metric-name:end'); 191 | }); 192 | 193 | it('should be called once', () => { 194 | const spyMark = jest.spyOn(window.performance, 'mark'); 195 | const spyMeasure = jest.spyOn(window.performance, 'measure'); 196 | 197 | PerfectumClient.init(); 198 | PerfectumClient.startMeasure('metric-name'); 199 | PerfectumClient.stopMeasure('metric-name'); 200 | PerfectumClient.stopMeasure('metric-name'); 201 | PerfectumClient.stopMeasure('metric-name'); 202 | 203 | expect(spyMark).toHaveBeenCalledTimes(2); 204 | expect(spyMeasure).toHaveBeenCalledTimes(1); 205 | expect(spyMark).toHaveBeenCalledWith('metric-name:start'); 206 | expect(spyMark).toHaveBeenCalledWith('metric-name:end'); 207 | expect(spyMeasure).toHaveBeenCalledWith('metric-name', 'metric-name:start', 'metric-name:end'); 208 | }); 209 | 210 | it('should not be called without calling .init()', () => { 211 | const spyMark = jest.spyOn(window.performance, 'mark'); 212 | const spyMeasure = jest.spyOn(window.performance, 'measure'); 213 | 214 | PerfectumClient.stopMeasure('metric-name'); 215 | 216 | expect(spyMark).toHaveBeenCalledTimes(0); 217 | expect(spyMeasure).toHaveBeenCalledTimes(0); 218 | }); 219 | 220 | it('should not be called without calling .startMeasure()', () => { 221 | const spyMark = jest.spyOn(window.performance, 'mark'); 222 | const spyMeasure = jest.spyOn(window.performance, 'measure'); 223 | 224 | PerfectumClient.init(); 225 | PerfectumClient.stopMeasure('metric-name'); 226 | 227 | expect(spyMark).toHaveBeenCalledTimes(0); 228 | expect(spyMeasure).toHaveBeenCalledTimes(0); 229 | }); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /packages/client/src/index.ts: -------------------------------------------------------------------------------- 1 | import Logger from './logger'; 2 | import Performance from './performance'; 3 | import MonitoringService from './monitoring'; 4 | import { isSupportedEnvironment } from './utils'; 5 | import { PerfectumClientConfig } from './types'; 6 | 7 | export default class PerfectumClient { 8 | private static logger: Logger; 9 | private static performance: Performance; 10 | private static isInitialized: boolean; 11 | private static isSupportedEnvironment: boolean; 12 | 13 | public static init(config?: PerfectumClientConfig) { 14 | try { 15 | if (this.isInitialized) { 16 | return; 17 | } 18 | 19 | if (isSupportedEnvironment()) { 20 | this.isSupportedEnvironment = true; 21 | } else { 22 | return; 23 | } 24 | 25 | const { 26 | logging, 27 | sendMetricsUrl, 28 | sendMetricsData, 29 | sendMetricsCallback, 30 | onMetricCallback, 31 | maxPaintTime = 60000, 32 | } = config || {}; 33 | 34 | this.logger = new Logger({ logging }); 35 | this.performance = new Performance({maxPaintTime, onMetricCallback}, this.logger); 36 | 37 | this.performance.init(); 38 | 39 | const metrics = this.performance.getMetrics(); 40 | 41 | const monitoringService = new MonitoringService({ 42 | sendMetricsUrl, 43 | sendMetricsData, 44 | sendMetricsCallback 45 | }, this.logger); 46 | 47 | monitoringService.init(metrics); 48 | 49 | this.isInitialized = true; 50 | } catch (error) { 51 | this.logger.printError('PerfectumClient:init', error); 52 | } 53 | } 54 | 55 | public static startMeasure(markName: string) { 56 | if (this.isInitialized && this.isSupportedEnvironment) { 57 | this.performance.startPerformanceMeasure(markName); 58 | } 59 | } 60 | 61 | public static stopMeasure(markName: string) { 62 | if (this.isInitialized && this.isSupportedEnvironment) { 63 | this.performance.stopPerformanceMeasure(markName); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/client/src/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { LoggerConfig, InitialLoggerConfig } from './types'; 2 | 3 | export default class Logger { 4 | private config: LoggerConfig = { 5 | logging: false 6 | }; 7 | 8 | constructor(initialConfig: InitialLoggerConfig = {}) { 9 | this.config = { 10 | ...this.config, 11 | ...initialConfig 12 | }; 13 | } 14 | 15 | printError(methodName: string, error: Error) { 16 | if (this.config.logging) { 17 | window.console.log(`${methodName} failed, because ${error.message}`); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/client/src/logger/types.ts: -------------------------------------------------------------------------------- 1 | export type LoggerConfig = { 2 | logging: boolean; 3 | }; 4 | 5 | export type InitialLoggerConfig = Partial; 6 | -------------------------------------------------------------------------------- /packages/client/src/monitoring/index.ts: -------------------------------------------------------------------------------- 1 | import Logger from '../logger'; 2 | import { MetricsStore } from '../performance/types'; 3 | import { MonitoringServiceConfig } from './types'; 4 | 5 | export default class MonitoringService { 6 | private logger: Logger; 7 | private config: MonitoringServiceConfig; 8 | 9 | constructor(config: MonitoringServiceConfig, logger: Logger) { 10 | this.config = config; 11 | this.logger = logger; 12 | } 13 | 14 | public init(metrics: MetricsStore) { 15 | if('visibilityState' in document) { 16 | window.addEventListener( 17 | 'visibilitychange', 18 | () => document.visibilityState === 'hidden' && this.sendMetricsHandler(metrics), 19 | false 20 | ); 21 | } else { 22 | window.addEventListener( 23 | 'pagehide', 24 | () => this.sendMetricsHandler(metrics), 25 | false 26 | ); 27 | } 28 | } 29 | 30 | private sendMetricsHandler(metrics: MetricsStore) { 31 | const { 32 | sendMetricsUrl, 33 | sendMetricsData, 34 | sendMetricsCallback 35 | } = this.config; 36 | 37 | try { 38 | if (sendMetricsCallback) { 39 | sendMetricsCallback(metrics); 40 | return; 41 | } 42 | 43 | if (sendMetricsUrl) { 44 | this.sendMetrics(metrics, sendMetricsUrl, sendMetricsData); 45 | } 46 | } catch (error) { 47 | this.logger.printError('Monitoring.init', error); 48 | } 49 | } 50 | 51 | private sendMetrics( 52 | metrics: MetricsStore, 53 | sendMetricsUrl: string, 54 | sendMetricsData?: Record 55 | ) { 56 | const data = JSON.stringify({ 57 | ...metrics, 58 | ...sendMetricsData 59 | }); 60 | 61 | window.navigator.sendBeacon(sendMetricsUrl, data); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/client/src/monitoring/types.ts: -------------------------------------------------------------------------------- 1 | import { MetricsStore } from "../performance/types"; 2 | 3 | export type MonitoringServiceConfig = { 4 | sendMetricsUrl?: string; 5 | sendMetricsData?: Record; 6 | sendMetricsCallback?: (metrics: MetricsStore) => void; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/client/src/performance/constants.ts: -------------------------------------------------------------------------------- 1 | import { EntryTypes } from './types'; 2 | 3 | export const DEFAULT_OBSERVED_ENTRY_TYPES = [ 4 | EntryTypes.paint, 5 | EntryTypes.element, 6 | EntryTypes.longTask, 7 | EntryTypes.navigation, 8 | EntryTypes.firstInput, 9 | EntryTypes.layoutShift, 10 | EntryTypes.largestContentfulPaint 11 | ]; 12 | -------------------------------------------------------------------------------- /packages/client/src/performance/index.ts: -------------------------------------------------------------------------------- 1 | import Logger from '../logger'; 2 | import { 3 | Metrics, 4 | EntryTypes, 5 | DeviceTypes, 6 | MetricsStore, 7 | CustomMetricsStore, 8 | LongTasksMetricStore, 9 | LongTasksMetricNames, 10 | ElementPerformanceEntry, 11 | FirstInputDelayMetricNames, 12 | LayoutShiftPerformanceEntry, 13 | NavigationTimingsMetricNames, 14 | DeviceInformationMetricNames, 15 | NetworkInformationMetricNames, 16 | FirstInputDelayPerformanceEntry, 17 | NavigatorWithConnectionProperty, 18 | NavigationTimingsPerformanceEntry, 19 | PerformanceServiceConfig, 20 | } from './types'; 21 | import { isMobileDevice } from './utils'; 22 | import { DEFAULT_OBSERVED_ENTRY_TYPES } from './constants'; 23 | 24 | export default class Performance { 25 | private metrics: MetricsStore = { 26 | [Metrics.longTasks]: null, 27 | [Metrics.firstPaint]: null, 28 | [Metrics.customMetrics]: null, 29 | [Metrics.firstInputDelay]: null, 30 | [Metrics.navigationTimings]: null, 31 | [Metrics.deviceInformation]: null, 32 | [Metrics.networkInformation]: null, 33 | [Metrics.firstContentfulPaint]: null, 34 | [Metrics.cumulativeLayoutShift]: null, 35 | [Metrics.largestContentfulPaint]: null 36 | }; 37 | 38 | private config: PerformanceServiceConfig; 39 | private logger: Logger; 40 | 41 | private observersForDisconnectAfterFirstInput: PerformanceObserver[] = []; 42 | 43 | constructor(config: PerformanceServiceConfig, logger: Logger) { 44 | this.config = config; 45 | this.logger = logger; 46 | } 47 | 48 | public init() { 49 | this.initDeviceInformation(); 50 | this.initNetworkInformation(); 51 | this.initPerformanceObservers(); 52 | } 53 | 54 | private initDeviceInformation() { 55 | this.metrics[Metrics.deviceInformation] = { 56 | [DeviceInformationMetricNames.type]: isMobileDevice() 57 | ? DeviceTypes.mobile 58 | : DeviceTypes.desktop 59 | } 60 | } 61 | 62 | private initNetworkInformation() { 63 | const { connection } = navigator as NavigatorWithConnectionProperty; 64 | 65 | if (!connection) { 66 | return; 67 | } 68 | 69 | this.setMetric(Metrics.networkInformation, { 70 | [NetworkInformationMetricNames.roundTripTime]: connection.rtt, 71 | [NetworkInformationMetricNames.downlinkBandwidth]: connection.downlink, 72 | [NetworkInformationMetricNames.effectiveConnectionType]: connection.effectiveType, 73 | [NetworkInformationMetricNames.saveData]: connection.saveData 74 | }); 75 | } 76 | 77 | private initPerformanceObservers() { 78 | this.observedEntryTypes.forEach((entryType: EntryTypes) => { 79 | switch (entryType) { 80 | case EntryTypes.paint: 81 | this.initFirstPaintObserver(); 82 | this.initFirstContentfulPaintObserver(); 83 | break; 84 | case EntryTypes.element: 85 | this.initElementTimingObserver(); 86 | break; 87 | case EntryTypes.longTask: 88 | this.initLongTasksObserver(); 89 | break; 90 | case EntryTypes.layoutShift: 91 | this.initLayoutShiftObserver(); 92 | break; 93 | case EntryTypes.firstInput: 94 | this.initFirstInputDelayObserver(); 95 | break; 96 | case EntryTypes.navigation: 97 | this.initNavigationTimingsObserver(); 98 | break; 99 | case EntryTypes.largestContentfulPaint: 100 | this.initLargestContentfulPaintObserver(); 101 | break; 102 | default: 103 | break; 104 | } 105 | }); 106 | } 107 | 108 | private initNavigationTimingsObserver() { 109 | try { 110 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 111 | const performanceEntry = performanceEntryList.getEntries()[0] as NavigationTimingsPerformanceEntry; 112 | 113 | const { 114 | startTime, 115 | connectEnd, 116 | responseEnd, 117 | requestStart, 118 | connectStart, 119 | responseStart, 120 | domainLookupEnd, 121 | domainLookupStart 122 | } = performanceEntry; 123 | 124 | this.setMetric(Metrics.navigationTimings, { 125 | [NavigationTimingsMetricNames.domainLookupTime]: domainLookupEnd - domainLookupStart, 126 | [NavigationTimingsMetricNames.serverResponseTime]: responseStart - requestStart, 127 | [NavigationTimingsMetricNames.serverConnectionTime]: connectEnd - connectStart, 128 | [NavigationTimingsMetricNames.downloadDocumentTime]: responseEnd - startTime 129 | }); 130 | 131 | performanceObserver.disconnect(); 132 | }); 133 | 134 | performanceObserver.observe({ type: EntryTypes.navigation, buffered: true }); 135 | } catch (error) { 136 | this.logger.printError('Performance:initNavigationTimingsObserver', error); 137 | } 138 | } 139 | 140 | private initFirstPaintObserver() { 141 | try { 142 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 143 | const performanceEntries = performanceEntryList.getEntries(); 144 | 145 | performanceEntries.forEach(performanceEntry => { 146 | if (performanceEntry.name === Metrics.firstPaint) { 147 | if(this.isPaintMetricValueValid(performanceEntry.startTime)) { 148 | this.setMetric(Metrics.firstPaint, performanceEntry.startTime); 149 | } 150 | 151 | performanceObserver.disconnect(); 152 | } 153 | }); 154 | }); 155 | 156 | performanceObserver.observe({ type: EntryTypes.paint, buffered: true }); 157 | } catch (error) { 158 | this.logger.printError('Performance:initFirstPaintObserver', error); 159 | } 160 | } 161 | 162 | private initFirstContentfulPaintObserver() { 163 | try { 164 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 165 | const performanceEntries = performanceEntryList.getEntries(); 166 | 167 | performanceEntries.forEach(performanceEntry => { 168 | if (performanceEntry.name === Metrics.firstContentfulPaint) { 169 | if(this.isPaintMetricValueValid(performanceEntry.startTime)) { 170 | this.setMetric(Metrics.firstContentfulPaint, performanceEntry.startTime); 171 | } 172 | 173 | performanceObserver.disconnect(); 174 | } 175 | }); 176 | }); 177 | 178 | performanceObserver.observe({ type: EntryTypes.paint, buffered: true }); 179 | } catch (error) { 180 | this.logger.printError('Performance:initFirstContentfulPaintObserver', error); 181 | } 182 | } 183 | 184 | private initFirstInputDelayObserver() { 185 | try { 186 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 187 | const performanceEntry = performanceEntryList.getEntries()[0] as FirstInputDelayPerformanceEntry; 188 | 189 | this.setMetric(Metrics.firstInputDelay, { 190 | [FirstInputDelayMetricNames.eventName]: performanceEntry.name, 191 | [FirstInputDelayMetricNames.startTime]: performanceEntry.startTime, 192 | [FirstInputDelayMetricNames.duration]: performanceEntry.processingStart - performanceEntry.startTime 193 | }); 194 | 195 | performanceObserver.disconnect(); 196 | 197 | // After the first interaction with the page, we disconnect some performance observers 198 | this.disconnectObserversAfterFirstInput(); 199 | }); 200 | 201 | performanceObserver.observe({ type: EntryTypes.firstInput, buffered: true }); 202 | } catch (error) { 203 | this.logger.printError('Performance:initFirstInputDelayObserver', error); 204 | } 205 | } 206 | 207 | private initLargestContentfulPaintObserver() { 208 | try { 209 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 210 | const performanceEntries = performanceEntryList.getEntries(); 211 | const performanceEntry = performanceEntries[performanceEntries.length - 1]; 212 | 213 | if(this.isPaintMetricValueValid(performanceEntry.startTime)) { 214 | this.setMetric(Metrics.largestContentfulPaint, performanceEntry.startTime); 215 | } 216 | }); 217 | 218 | performanceObserver.observe({ type: EntryTypes.largestContentfulPaint, buffered: true }); 219 | 220 | } catch (error) { 221 | this.logger.printError('Performance:initLargestContentfulPaintObserver', error); 222 | } 223 | } 224 | 225 | private initLayoutShiftObserver() { 226 | try { 227 | if (this.metrics[Metrics.cumulativeLayoutShift] === null) { 228 | this.metrics[Metrics.cumulativeLayoutShift] = 0; 229 | } 230 | 231 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 232 | const performanceEntries = performanceEntryList.getEntries() as LayoutShiftPerformanceEntry[]; 233 | let totalShift = this.metrics[Metrics.cumulativeLayoutShift] || 0; 234 | 235 | performanceEntries.forEach((performanceEntry) => { 236 | if (!performanceEntry.hadRecentInput) { 237 | totalShift += performanceEntry.value; 238 | } 239 | }); 240 | 241 | this.setMetric(Metrics.cumulativeLayoutShift, totalShift); 242 | }); 243 | 244 | performanceObserver.observe({ type: EntryTypes.layoutShift, buffered: true }); 245 | } catch (error) { 246 | this.logger.printError('Performance:initLayoutShiftObserver', error); 247 | } 248 | } 249 | 250 | private initLongTasksObserver() { 251 | try { 252 | if (this.metrics[Metrics.longTasks] === null) { 253 | this.metrics[Metrics.longTasks] = { 254 | [LongTasksMetricNames.totalLongTasks]: 0, 255 | [LongTasksMetricNames.firstLongTaskDuration]: 0, 256 | [LongTasksMetricNames.firstLongTaskStartTime]: 0 257 | }; 258 | } 259 | 260 | const longTasksMetricStore = this.metrics[Metrics.longTasks] as LongTasksMetricStore; 261 | 262 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 263 | const performanceEntries = performanceEntryList.getEntries(); 264 | 265 | longTasksMetricStore[LongTasksMetricNames.totalLongTasks] += performanceEntries.length; 266 | 267 | if (longTasksMetricStore[LongTasksMetricNames.firstLongTaskDuration] === 0) { 268 | const firstPerformanceEntry = performanceEntries[0]; 269 | 270 | longTasksMetricStore[LongTasksMetricNames.firstLongTaskDuration] = firstPerformanceEntry.duration; 271 | longTasksMetricStore[LongTasksMetricNames.firstLongTaskStartTime] = firstPerformanceEntry.startTime; 272 | } 273 | 274 | this.setMetric(Metrics.longTasks, longTasksMetricStore); 275 | }); 276 | 277 | performanceObserver.observe({ type: EntryTypes.longTask }); 278 | 279 | this.observersForDisconnectAfterFirstInput.push(performanceObserver); 280 | } catch (error) { 281 | this.logger.printError('Performance:initLongTasksObserver', error); 282 | } 283 | } 284 | 285 | private initElementTimingObserver() { 286 | try { 287 | if (this.metrics[Metrics.customMetrics] === null) { 288 | this.metrics[Metrics.customMetrics] = {}; 289 | } 290 | 291 | const customMetricsStore = this.metrics[Metrics.customMetrics] as CustomMetricsStore; 292 | 293 | const performanceObserver = new PerformanceObserver((performanceEntryList: PerformanceObserverEntryList) => { 294 | const performanceEntries = performanceEntryList.getEntries() as ElementPerformanceEntry[]; 295 | 296 | performanceEntries.forEach((performanceEntry) => { 297 | if (customMetricsStore[performanceEntry.identifier] === undefined) { 298 | customMetricsStore[performanceEntry.identifier] = performanceEntry.startTime; 299 | } 300 | }); 301 | }); 302 | 303 | performanceObserver.observe({ type: EntryTypes.element, buffered: true }); 304 | 305 | this.observersForDisconnectAfterFirstInput.push(performanceObserver); 306 | } catch (error) { 307 | this.logger.printError('Performance:initElementTimingObserver', error); 308 | } 309 | } 310 | 311 | private disconnectObserversAfterFirstInput() { 312 | this.observersForDisconnectAfterFirstInput.forEach(performanceObserver => { 313 | performanceObserver.disconnect(); 314 | }); 315 | } 316 | 317 | private get observedEntryTypes() { 318 | const supportedEntryTypes = PerformanceObserver?.supportedEntryTypes?.length 319 | ? PerformanceObserver.supportedEntryTypes 320 | : null; 321 | 322 | return supportedEntryTypes 323 | ? DEFAULT_OBSERVED_ENTRY_TYPES.filter(entryType => supportedEntryTypes.indexOf(entryType) !== -1) // eslint-disable-line @typescript-eslint/prefer-includes 324 | : DEFAULT_OBSERVED_ENTRY_TYPES; 325 | } 326 | 327 | private isPaintMetricValueValid(value: number): boolean { 328 | return value >= 0 && value <= this.config.maxPaintTime; 329 | } 330 | 331 | private setMetric(metric: T, metricValue: MetricsStore[T]) { 332 | this.metrics[metric] = metricValue; 333 | 334 | this.config.onMetricCallback && this.config.onMetricCallback(metric, metricValue, this.metrics); 335 | } 336 | 337 | public startPerformanceMeasure(markName: string) { 338 | try { 339 | if (this.metrics[Metrics.customMetrics] === null) { 340 | this.metrics[Metrics.customMetrics] = {}; 341 | } 342 | 343 | const customMetricsStore = this.metrics[Metrics.customMetrics] as CustomMetricsStore; 344 | 345 | if (customMetricsStore[markName] === undefined) { 346 | customMetricsStore[markName] = null; 347 | performance.mark(`${markName}:start`); 348 | } 349 | } catch (error) { 350 | this.logger.printError('Performance:startMeasure', error); 351 | } 352 | } 353 | 354 | public stopPerformanceMeasure(markName: string) { 355 | try { 356 | const customMetricsStore = this.metrics[Metrics.customMetrics]; 357 | 358 | if (customMetricsStore === null) { 359 | return; 360 | } 361 | 362 | if (customMetricsStore[markName] === null) { 363 | performance.mark(`${markName}:end`); 364 | performance.measure(markName, `${markName}:start`, `${markName}:end`); 365 | 366 | const performanceEntries = performance.getEntriesByName(markName); 367 | 368 | performanceEntries.forEach(performanceEntry => { 369 | if (performanceEntry.entryType === EntryTypes.measure) { 370 | customMetricsStore[markName] = performanceEntry.duration; 371 | } 372 | }) 373 | 374 | this.setMetric(Metrics.customMetrics, customMetricsStore); 375 | } 376 | } catch (error) { 377 | this.logger.printError('Performance:stopMeasure', error); 378 | } 379 | } 380 | 381 | public getMetrics() { 382 | return this.metrics; 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /packages/client/src/performance/types.ts: -------------------------------------------------------------------------------- 1 | export enum EntryTypes { 2 | paint = 'paint', 3 | element = 'element', 4 | measure = 'measure', 5 | longTask = 'longtask', 6 | navigation = 'navigation', 7 | firstInput = 'first-input', 8 | layoutShift = 'layout-shift', 9 | largestContentfulPaint = 'largest-contentful-paint' 10 | } 11 | 12 | export enum Metrics { 13 | longTasks = 'long-tasks', 14 | firstPaint = 'first-paint', 15 | customMetrics = 'custom-metrics', 16 | firstInputDelay = 'first-input-delay', 17 | navigationTimings = 'navigation-timings', 18 | deviceInformation = 'device-information', 19 | networkInformation = 'network-information', 20 | firstContentfulPaint = 'first-contentful-paint', 21 | cumulativeLayoutShift = 'cumulative-layout-shift', 22 | largestContentfulPaint = 'largest-contentful-paint' 23 | } 24 | 25 | export type MetricsStore = { 26 | [Metrics.longTasks]: LongTasksMetricStore | null; 27 | [Metrics.firstPaint]: FirstPaintMetricStore | null; 28 | [Metrics.customMetrics]: CustomMetricsStore | null; 29 | [Metrics.firstInputDelay]: FirstInputDelayMetricStore | null; 30 | [Metrics.navigationTimings]: NavigationTimingsMetricStore | null; 31 | [Metrics.deviceInformation]: DeviceInformationMetricStore | null; 32 | [Metrics.networkInformation]: NetworkInformationMetricStore | null; 33 | [Metrics.firstContentfulPaint]: FirstContentfulPaintMetricStore | null; 34 | [Metrics.cumulativeLayoutShift]: CumulativeLayoutShiftMetricStore | null; 35 | [Metrics.largestContentfulPaint]: LargestContentfulPaintMetricStore | null; 36 | }; 37 | 38 | export enum NavigationTimingsMetricNames { 39 | domainLookupTime = 'domain-lookup-time', 40 | serverResponseTime = 'server-response-time', 41 | serverConnectionTime = 'server-connection-time', 42 | downloadDocumentTime = 'download-document-time' 43 | } 44 | 45 | type NavigationTimingsMetricStore = Record; 46 | 47 | export enum DeviceInformationMetricNames { 48 | type = 'type' 49 | } 50 | 51 | type DeviceInformationMetricStore = { 52 | [DeviceInformationMetricNames.type]: DeviceTypes; 53 | }; 54 | 55 | export enum NetworkInformationMetricNames { 56 | roundTripTime = 'round-trip-time', 57 | downlinkBandwidth = 'downlink-bandwidth', 58 | effectiveConnectionType = 'effective-connection-type', 59 | saveData = 'save-data' 60 | } 61 | 62 | type NetworkInformationMetricStore = { 63 | [NetworkInformationMetricNames.roundTripTime]: number; 64 | [NetworkInformationMetricNames.downlinkBandwidth]: number; 65 | [NetworkInformationMetricNames.effectiveConnectionType]: string; 66 | [NetworkInformationMetricNames.saveData]: boolean | undefined; 67 | }; 68 | 69 | type ConnectionProperty = { 70 | rtt: number; 71 | downlink: number; 72 | effectiveType: string; 73 | saveData?: boolean; 74 | } 75 | 76 | export type NavigatorWithConnectionProperty = Navigator & { 77 | connection?: ConnectionProperty; 78 | } 79 | 80 | export type CustomMetricsStore = Record; 81 | 82 | export enum LongTasksMetricNames { 83 | totalLongTasks = 'total-long-tasks', 84 | firstLongTaskDuration = 'first-long-task-duration', 85 | firstLongTaskStartTime = 'first-long-task-start-time' 86 | } 87 | 88 | export type LongTasksMetricStore = Record; 89 | 90 | export enum FirstInputDelayMetricNames { 91 | eventName = 'event-name', 92 | startTime = 'start-time', 93 | duration = 'duration' 94 | } 95 | 96 | type FirstInputDelayMetricStore = { 97 | [FirstInputDelayMetricNames.eventName]: string; 98 | [FirstInputDelayMetricNames.startTime]: number; 99 | [FirstInputDelayMetricNames.duration]: number; 100 | }; 101 | 102 | type FirstPaintMetricStore = number; 103 | 104 | type FirstContentfulPaintMetricStore = number; 105 | 106 | type CumulativeLayoutShiftMetricStore = number; 107 | 108 | type LargestContentfulPaintMetricStore = number; 109 | 110 | export enum DeviceTypes { 111 | mobile = 'mobile', 112 | desktop = 'desktop' 113 | } 114 | 115 | export type FirstInputDelayPerformanceEntry = PerformanceEntry & { 116 | processingEnd: number; 117 | processingStart: number; 118 | }; 119 | 120 | export type LayoutShiftPerformanceEntry = PerformanceEntry & { 121 | value: number; 122 | lastInputTime: number; 123 | hadRecentInput: boolean; 124 | }; 125 | 126 | export type ElementPerformanceEntry = PerformanceEntry & { 127 | id: string; 128 | url: string; 129 | loadTime: number; 130 | renderTime: number; 131 | identifier: string; 132 | }; 133 | 134 | export type NavigationTimingsPerformanceEntry = PerformanceNavigationTiming; 135 | 136 | export type PerformanceServiceConfig = { 137 | maxPaintTime: number; 138 | onMetricCallback?: (metric: T, metricValue: MetricsStore[T], allMetrics: MetricsStore) => void; 139 | }; 140 | -------------------------------------------------------------------------------- /packages/client/src/performance/utils.ts: -------------------------------------------------------------------------------- 1 | export const isMobileDevice = () => { 2 | if (navigator.userAgent.indexOf('Mobi') !== -1) { // eslint-disable-line @typescript-eslint/prefer-includes 3 | return true 4 | } 5 | 6 | return false 7 | }; 8 | -------------------------------------------------------------------------------- /packages/client/src/types.ts: -------------------------------------------------------------------------------- 1 | import { LoggerConfig } from './logger/types'; 2 | import { MonitoringServiceConfig } from './monitoring/types'; 3 | import { PerformanceServiceConfig } from './performance/types'; 4 | 5 | export type PerfectumClientConfig = Partial< 6 | LoggerConfig & MonitoringServiceConfig & PerformanceServiceConfig 7 | >; 8 | -------------------------------------------------------------------------------- /packages/client/src/utils.ts: -------------------------------------------------------------------------------- 1 | const isBrowserEnvironment = () => ( 2 | typeof window !== 'undefined' 3 | ) 4 | 5 | const isPageStateVisible = () => ( 6 | window.document && 7 | window.document.hidden !== undefined && 8 | window.document.hidden === false 9 | ) 10 | 11 | const isSupportNavigatorApi = () => ( 12 | 'navigator' in window 13 | ) 14 | 15 | const isSupportSendBeaconApi = () => ( 16 | 'sendBeacon' in navigator 17 | ) 18 | 19 | const isSupportPerformanceApi = () => ( 20 | 'performance' in window 21 | ) 22 | 23 | const isSupportUserTimingApi = () => ( 24 | 'mark' in performance && 25 | 'measure' in performance 26 | ) 27 | 28 | const isSupportPerformanceObserverApi = () => ( 29 | 'PerformanceObserver' in window 30 | ) 31 | 32 | const isSupportSupportedEntryTypesProperty = () => ( 33 | 'supportedEntryTypes' in PerformanceObserver 34 | ) 35 | 36 | export const isSupportedEnvironment = () => ( 37 | isBrowserEnvironment() && 38 | isPageStateVisible() && 39 | isSupportNavigatorApi() && 40 | isSupportSendBeaconApi() && 41 | isSupportPerformanceApi() && 42 | isSupportUserTimingApi() && 43 | isSupportPerformanceObserverApi() && 44 | isSupportSupportedEntryTypesProperty() 45 | ) 46 | -------------------------------------------------------------------------------- /packages/client/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [] 4 | } 5 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "rootDir": "./src", 6 | "incremental": true, 7 | "outDir": "./lib/transpiled", 8 | "declarationDir": "./lib/typings", 9 | "tsBuildInfoFile": "./lib/tsconfig.tsbuildinfo" 10 | }, 11 | "exclude": [ 12 | "**/__tests__/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/synthetic/README.md: -------------------------------------------------------------------------------- 1 | # Perfectum Synthetic 2 | Library for measuring synthetic performance metrics :vertical_traffic_light: 3 | 4 | ## Features 5 | * Build and launch your project :rocket: 6 | * Authentication before performance audit :ticket: 7 | * Reporting for desktop and mobile devices :clipboard: 8 | * Using performance budgets, including segregation by device type and application page :rotating_light: 9 | 10 | ## Built With 11 | * [Chrome Launcher](https://github.com/GoogleChrome/chrome-launcher) 12 | * [Lighthouse](https://github.com/GoogleChrome/lighthouse) 13 | * [Puppeteer](https://github.com/puppeteer/puppeteer) 14 | 15 | ## Installation 16 | ```sh 17 | yarn add @perfectum/synthetic -D 18 | ``` 19 | 20 | ## Usage 21 | For more convenient work with this package, we recommend using [Perfectum CLI](../cli/). 22 | 23 | ```javascript 24 | import Perfectum from '@perfectum/synthetic'; 25 | 26 | new Perfectum({ 27 | urls: { 28 | main: 'https://www.example.com/', 29 | profile: 'https://www.example.com/profile/', 30 | }, 31 | budgets: [ 32 | { 33 | 'url': 'main', 34 | 'first-contentful-paint': { 35 | 'mobile': { 36 | 'target': 2150, 37 | 'current': 2650 38 | }, 39 | 'desktop': { 40 | 'target': 1850, 41 | 'current': 2350 42 | } 43 | }, 44 | 'interactive': { 45 | 'mobile': { 46 | 'target': 2950, 47 | 'current': 3550 48 | }, 49 | 'desktop': { 50 | 'target': 2650, 51 | 'current': 3250 52 | } 53 | } 54 | } 55 | ], 56 | numberOfAuditRuns: 5, 57 | buildProjectTimeout: 10, 58 | startProjectTimeout: 10, 59 | buildProjectCommand: 'yarn run build', 60 | startProjectCommand: 'yarn run start', 61 | buildProjectCompleteStringPattern: 'The project was built', 62 | startProjectCompleteStringPattern: 'You can now view example in the browser' 63 | }); 64 | ``` 65 | -------------------------------------------------------------------------------- /packages/synthetic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perfectum/synthetic", 3 | "version": "1.1.0", 4 | "description": "Library for measuring synthetic performance metrics", 5 | "author": "Zakharov Vladislav ", 6 | "license": "Apache-2.0", 7 | "main": "lib/index.js", 8 | "typings": "lib/typings/index.d.ts", 9 | "scripts": { 10 | "prebuild": "rimraf lib", 11 | "build": "tsc", 12 | "lint": "lint-ts", 13 | "lint-staged": "lint-staged", 14 | "lint-ts": "eslint 'src/**/*.ts'" 15 | }, 16 | "dependencies": { 17 | "chrome-launcher": "0.11.1", 18 | "deepmerge": "4.2.2", 19 | "kleur": "3.0.3", 20 | "lighthouse": "6.0.0", 21 | "node-emoji": "1.10.0", 22 | "node-fetch": "2.6.1", 23 | "puppeteer": "2.1.1", 24 | "tree-kill": "1.2.2", 25 | "ts-node": "8.8.2" 26 | }, 27 | "devDependencies": { 28 | "@types/node-emoji": "1.8.1", 29 | "@types/node-fetch": "2.5.6", 30 | "@types/puppeteer": "2.0.1" 31 | }, 32 | "files": [ 33 | "lib" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/configs/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LIGHTHOUSE_AUDIT_CONFIG_NAME = 'lighthouse:default'; 2 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/configs/desktop.ts: -------------------------------------------------------------------------------- 1 | import { LighthouseConfig, EmulatedFormFactor } from './types'; 2 | import { DEFAULT_LIGHTHOUSE_AUDIT_CONFIG_NAME } from './constants'; 3 | 4 | export const desktopAuditConfig: LighthouseConfig = { 5 | extends: DEFAULT_LIGHTHOUSE_AUDIT_CONFIG_NAME, 6 | settings: { 7 | maxWaitForFcp: 15 * 1000, 8 | maxWaitForLoad: 35 * 1000, 9 | emulatedFormFactor: EmulatedFormFactor.desktop, 10 | throttling: { 11 | rttMs: 100, 12 | throughputKbps: 75 * 1024, 13 | cpuSlowdownMultiplier: 1 14 | }, 15 | budgets: null 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/configs/index.ts: -------------------------------------------------------------------------------- 1 | import { DeviceTypes } from '../../types'; 2 | import { AuditConfig } from './types'; 3 | import { mobileAuditConfig } from './mobile'; 4 | import { desktopAuditConfig } from './desktop'; 5 | 6 | export const defaultAuditConfig: AuditConfig = { 7 | [DeviceTypes.mobile]: mobileAuditConfig, 8 | [DeviceTypes.desktop]: desktopAuditConfig 9 | } 10 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/configs/mobile.ts: -------------------------------------------------------------------------------- 1 | import { LighthouseConfig, EmulatedFormFactor } from './types'; 2 | import { DEFAULT_LIGHTHOUSE_AUDIT_CONFIG_NAME } from './constants'; 3 | 4 | export const mobileAuditConfig: LighthouseConfig = { 5 | extends: DEFAULT_LIGHTHOUSE_AUDIT_CONFIG_NAME, 6 | settings: { 7 | maxWaitForFcp: 15 * 1000, 8 | maxWaitForLoad: 35 * 1000, 9 | emulatedFormFactor: EmulatedFormFactor.mobile, 10 | throttling: { 11 | rttMs: 115, 12 | throughputKbps: 40 * 1024, 13 | cpuSlowdownMultiplier: 1 14 | }, 15 | budgets: null 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/configs/types.ts: -------------------------------------------------------------------------------- 1 | import { DeviceTypes } from '../../types'; 2 | import { SyntheticPerformanceMetrics, ResourceTypes } from '../../types'; 3 | 4 | export type AuditConfig = Record; 5 | 6 | export type LighthouseConfig = { 7 | extends: string; 8 | settings: LighthouseSettings; 9 | }; 10 | 11 | type LighthouseSettings = { 12 | maxWaitForFcp: number; 13 | maxWaitForLoad: number; 14 | budgets: LighthouseBudget[] | null; 15 | throttling: Record; 16 | emulatedFormFactor: EmulatedFormFactor; 17 | }; 18 | 19 | export type LighthouseBudget = { 20 | timings: Record[]; 21 | resourceSizes: Record[]; 22 | resourceCounts: Record[]; 23 | } 24 | 25 | export enum EmulatedFormFactor { 26 | mobile = 'mobile', 27 | desktop = 'desktop' 28 | } 29 | 30 | export enum Throttling { 31 | rttMs = 'rttMs', 32 | throughputKbps = 'throughputKbps', 33 | cpuSlowdownMultiplier = 'cpuSlowdownMultiplier' 34 | } 35 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_NUMBER_OF_AUDIT_RUNS = 3; 2 | export const AUDIT_CONFIGS_DIRECTORY_NAME = 'configs'; 3 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import emoji from 'node-emoji'; 3 | import deepmerge from 'deepmerge'; 4 | import lighthouse from 'lighthouse'; 5 | import Logger from '../logger'; 6 | import Reporter from '../reporter'; 7 | import { createLighthouseBudget } from './utils/create-lighthouse-budget'; 8 | import { DeviceTypes, SyntheticPerformanceBudget } from '../types'; 9 | import { AuditConfig } from './configs/types'; 10 | import { defaultAuditConfig } from './configs'; 11 | import { AuditorConfig, AuditResult } from './types'; 12 | import { DEFAULT_NUMBER_OF_AUDIT_RUNS, AUDIT_CONFIGS_DIRECTORY_NAME } from './constants'; 13 | 14 | class Auditor { 15 | private config: AuditorConfig; 16 | private reporter: Reporter; 17 | private logger: Logger; 18 | 19 | constructor( 20 | logger: Logger, 21 | reporter: Reporter, 22 | browserPort: number, 23 | initialAuditConfig?: AuditConfig 24 | ) { 25 | this.logger = logger; 26 | this.reporter = reporter; 27 | 28 | const auditConfig = initialAuditConfig 29 | ? deepmerge(defaultAuditConfig, initialAuditConfig) 30 | : defaultAuditConfig; 31 | 32 | const auditOptions = { 33 | port: browserPort, 34 | configPath: path.resolve(__dirname, AUDIT_CONFIGS_DIRECTORY_NAME), 35 | }; 36 | 37 | this.config = { 38 | auditConfig, 39 | auditOptions 40 | } 41 | } 42 | 43 | async run( 44 | url: string, 45 | urlName: string, 46 | numberOfAuditRuns?: number, 47 | performanceBudget?: SyntheticPerformanceBudget, 48 | ) { 49 | try { 50 | await this.runMobilePerformanceAudit(url, urlName, numberOfAuditRuns, performanceBudget); 51 | await this.runDesktopPerformanceAudit(url, urlName, numberOfAuditRuns, performanceBudget); 52 | } catch (error) { 53 | throw new Error(`Running performance audit failed\n\n${error}`); 54 | } 55 | } 56 | 57 | private async runMobilePerformanceAudit( 58 | url: string, 59 | urlName: string, 60 | numberOfAuditRuns?: number, 61 | performanceBudget?: SyntheticPerformanceBudget 62 | ) { 63 | await this.runPerformanceAudit( 64 | url, 65 | urlName, 66 | DeviceTypes.mobile, 67 | numberOfAuditRuns, 68 | performanceBudget 69 | ); 70 | } 71 | 72 | private async runDesktopPerformanceAudit( 73 | url: string, 74 | urlName: string, 75 | numberOfAuditRuns?: number, 76 | performanceBudget?: SyntheticPerformanceBudget 77 | ) { 78 | await this.runPerformanceAudit( 79 | url, 80 | urlName, 81 | DeviceTypes.desktop, 82 | numberOfAuditRuns, 83 | performanceBudget 84 | ); 85 | } 86 | 87 | private async runPerformanceAudit( 88 | url: string, 89 | urlName: string, 90 | deviceType: DeviceTypes, 91 | numberOfAuditRuns = DEFAULT_NUMBER_OF_AUDIT_RUNS, 92 | performanceBudget?: SyntheticPerformanceBudget, 93 | ) { 94 | const { 95 | auditConfig, 96 | auditOptions 97 | } = this.config; 98 | 99 | if (performanceBudget) { 100 | const lighthouseBudget = createLighthouseBudget(performanceBudget, deviceType); 101 | 102 | auditConfig[deviceType].settings.budgets = [lighthouseBudget]; 103 | } else { 104 | auditConfig[deviceType].settings.budgets = null; 105 | } 106 | 107 | const auditResults: AuditResult[] = []; 108 | 109 | for (let run = 1; run <= numberOfAuditRuns; run++) { 110 | this.logger.print(`${emoji.get('mag_right')} Running ${numberOfAuditRuns > 1 ? `${run} of ${numberOfAuditRuns} ` : ''}performance audit for ${deviceType} devices...`); 111 | 112 | const auditResult = await lighthouse( 113 | url, 114 | auditOptions, 115 | auditConfig[deviceType] 116 | ) as AuditResult; 117 | 118 | auditResults.push(auditResult); 119 | } 120 | 121 | const medianAuditResult = this.getMedianAuditResult(auditResults, deviceType); 122 | 123 | if (medianAuditResult) { 124 | this.reporter.createReport(medianAuditResult, urlName, deviceType); 125 | } 126 | } 127 | 128 | private getMedianAuditResult(auditResults: AuditResult[], deviceType: DeviceTypes) { 129 | if (auditResults.length === 0) { 130 | this.logger.printWarning(`\n${emoji.get('warning')} No audit results found for ${deviceType} devices\n`); 131 | 132 | return null; 133 | } 134 | 135 | const validAuditResults = this.validateAuditResultsByPerformanceCategoryScore(auditResults); 136 | 137 | if (validAuditResults.length === 0) { 138 | this.logger.printWarning(`\n${emoji.get('warning')} No valid audit results found for ${deviceType} devices\n`); 139 | 140 | return null; 141 | } 142 | 143 | const sortedAuditResults = this.sortAuditResultsByPerformanceCategoryScore(validAuditResults); 144 | 145 | const middleIndexOfSortedAuditResultsArray = Math.floor(sortedAuditResults.length / 2); 146 | 147 | // We do not consider the case of an even number of samples (audit results) due to the complex structure of the analyzed data 148 | const medianAuditResult = sortedAuditResults[middleIndexOfSortedAuditResultsArray]; 149 | 150 | return medianAuditResult; 151 | } 152 | 153 | private validateAuditResultsByPerformanceCategoryScore(auditResults: AuditResult[]) { 154 | return auditResults.filter(auditResult => { 155 | const performanceCategoryScore = auditResult?.lhr?.categories?.performance?.score; 156 | 157 | if (typeof performanceCategoryScore === 'number' && !isNaN(performanceCategoryScore)) { 158 | return true; 159 | } 160 | 161 | return false; 162 | }); 163 | } 164 | 165 | private sortAuditResultsByPerformanceCategoryScore(auditResults: AuditResult[]) { 166 | return auditResults.sort((a, b) => { 167 | const performanceCategoryScoreOfFirstAuditResult = a?.lhr?.categories?.performance?.score as number; 168 | const performanceCategoryScoreOfSecondAuditResult = b?.lhr?.categories?.performance?.score as number; 169 | 170 | return performanceCategoryScoreOfFirstAuditResult - performanceCategoryScoreOfSecondAuditResult; 171 | }); 172 | } 173 | } 174 | 175 | export default Auditor; 176 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/types.ts: -------------------------------------------------------------------------------- 1 | import { DeviceTypes } from '../types'; 2 | import { AuditConfig } from './configs/types'; 3 | 4 | export type AuditorConfig = { 5 | auditConfig: AuditConfig; 6 | auditOptions: AuditOptions; 7 | } 8 | 9 | export type AuditOptions = { 10 | port: number; 11 | configPath: string; 12 | }; 13 | 14 | export type AuditResultStore = { 15 | [DeviceTypes.mobile]: AuditResult | null; 16 | [DeviceTypes.desktop]: AuditResult | null; 17 | } 18 | 19 | export type AuditResult = { 20 | lhr: { 21 | i18n: string; 22 | audits: string; 23 | timing: string; 24 | finalUrl: string; 25 | userAgent: string; 26 | fetchTime: string; 27 | stackPacks: string; 28 | categories: Record; 29 | environment: string; 30 | runWarnings: string; 31 | runtimeError: string; 32 | requestedUrl: string; 33 | configSettings: string; 34 | categoryGroups: string; 35 | lighthouseVersion: string; 36 | }; 37 | } 38 | 39 | type Category = { 40 | id: string; 41 | title: string; 42 | description?: string; 43 | manualDescription?: string; 44 | score: number | null; 45 | } 46 | -------------------------------------------------------------------------------- /packages/synthetic/src/auditor/utils/create-lighthouse-budget.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeviceTypes, 3 | ResourceTypes, 4 | MetricValueTypes, 5 | SyntheticPerformanceBudget, 6 | SyntheticPerformanceMetrics 7 | } from '../../types'; 8 | import { LighthouseBudget } from '../configs/types'; 9 | 10 | export const createLighthouseBudget = ( 11 | performanceBudget: SyntheticPerformanceBudget, 12 | deviceType: DeviceTypes, 13 | metricValueType = MetricValueTypes.current 14 | ) => { 15 | const lighthouseBudget: LighthouseBudget = { 16 | timings: [], 17 | resourceSizes: [], 18 | resourceCounts: [] 19 | }; 20 | 21 | const validMetricNames = getValidMetricNames(performanceBudget); 22 | const timingsMetricNames = getTimingsMetricNames(validMetricNames); 23 | const resourceMetricNames = getResourceMetricNames(validMetricNames); 24 | 25 | addTimingsMetricTresholdsToLighthouseBudget( 26 | timingsMetricNames, 27 | performanceBudget, 28 | lighthouseBudget, 29 | deviceType, 30 | metricValueType 31 | ); 32 | 33 | addResourceMetricTresholdsToLighthouseBudget( 34 | resourceMetricNames, 35 | performanceBudget, 36 | lighthouseBudget, 37 | deviceType, 38 | metricValueType 39 | ); 40 | 41 | return lighthouseBudget; 42 | } 43 | 44 | function getValidMetricNames(performanceBudget: SyntheticPerformanceBudget) { 45 | const allowedMetricNames = Object.values(SyntheticPerformanceMetrics); 46 | const metricNamesFromPerformanceBudget = Object.keys(performanceBudget) as SyntheticPerformanceMetrics[]; 47 | 48 | return metricNamesFromPerformanceBudget.filter( 49 | metricName => allowedMetricNames.includes(metricName) 50 | ); 51 | } 52 | 53 | function getTimingsMetricNames(metricNames: SyntheticPerformanceMetrics[]) { 54 | return metricNames.filter(metricName => { 55 | if ( 56 | metricName === SyntheticPerformanceMetrics.resourceSizes || 57 | metricName === SyntheticPerformanceMetrics.resourceRequests 58 | ) { 59 | return false; 60 | } 61 | 62 | return true; 63 | }); 64 | } 65 | 66 | function getResourceMetricNames(metricNames: SyntheticPerformanceMetrics[]) { 67 | return metricNames.filter(metricName => { 68 | if ( 69 | metricName === SyntheticPerformanceMetrics.resourceSizes || 70 | metricName === SyntheticPerformanceMetrics.resourceRequests 71 | ) { 72 | return true; 73 | } 74 | 75 | return false; 76 | }); 77 | } 78 | 79 | function isValidBudgetValue(budgetValue: number | string) { 80 | return typeof budgetValue === 'number' && !isNaN(budgetValue); 81 | } 82 | 83 | function addTimingsMetricTresholdsToLighthouseBudget( 84 | timingsMetricNames: SyntheticPerformanceMetrics[], 85 | performanceBudget: SyntheticPerformanceBudget, 86 | lighthouseBudget: LighthouseBudget, 87 | deviceType: DeviceTypes, 88 | metricValueType: MetricValueTypes 89 | ) { 90 | timingsMetricNames.forEach(metricName => { 91 | const budgetValue = performanceBudget[metricName]?.[deviceType]?.[metricValueType]; 92 | 93 | if (!isValidBudgetValue(budgetValue)) { 94 | return; 95 | } 96 | 97 | lighthouseBudget.timings.push({ 98 | metric: metricName, 99 | budget: budgetValue 100 | }); 101 | }) 102 | } 103 | 104 | function addResourceMetricTresholdsToLighthouseBudget( 105 | resourceMetricNames: SyntheticPerformanceMetrics[], 106 | performanceBudget: SyntheticPerformanceBudget, 107 | lighthouseBudget: LighthouseBudget, 108 | deviceType: DeviceTypes, 109 | metricValueType: MetricValueTypes 110 | ) { 111 | resourceMetricNames.forEach(metricName => { 112 | if (metricName === SyntheticPerformanceMetrics.resourceSizes) { 113 | const resourceTypes = Object.keys(performanceBudget[metricName]) as ResourceTypes[]; 114 | 115 | resourceTypes.forEach((resourceType) => { 116 | const budgetValue = performanceBudget[metricName]?.[resourceType]?.[deviceType]?.[metricValueType]; 117 | 118 | if (!isValidBudgetValue(budgetValue)) { 119 | return; 120 | } 121 | 122 | lighthouseBudget.resourceSizes.push({ 123 | resourceType, 124 | budget: budgetValue 125 | }); 126 | }) 127 | 128 | return; 129 | } 130 | 131 | if (metricName === SyntheticPerformanceMetrics.resourceRequests) { 132 | const resourceTypes = Object.keys(performanceBudget[metricName]) as ResourceTypes[]; 133 | 134 | resourceTypes.forEach((resourceType) => { 135 | const budgetValue = performanceBudget[metricName]?.[resourceType]?.[deviceType]?.[metricValueType]; 136 | 137 | if (!isValidBudgetValue(budgetValue)) { 138 | return; 139 | } 140 | 141 | lighthouseBudget.resourceCounts.push({ 142 | resourceType, 143 | budget: budgetValue 144 | }); 145 | }) 146 | 147 | return; 148 | } 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /packages/synthetic/src/authenticator/constants.ts: -------------------------------------------------------------------------------- 1 | export const TYPESCRIPT_FILE_EXTENSION = '.ts'; 2 | -------------------------------------------------------------------------------- /packages/synthetic/src/authenticator/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import emoji from 'node-emoji'; 4 | import puppeteer from 'puppeteer'; 5 | import Logger from '../logger'; 6 | import { TYPESCRIPT_FILE_EXTENSION } from './constants'; 7 | import { AuthenticatorConfig, AuthenticationScript } from './types'; 8 | 9 | class Authenticator { 10 | private logger: Logger; 11 | private config: AuthenticatorConfig; 12 | private authenticationScript: AuthenticationScript | null = null; 13 | 14 | constructor(config: AuthenticatorConfig, logger: Logger) { 15 | this.config = config; 16 | this.logger = logger; 17 | } 18 | 19 | async run(url: string) { 20 | const { 21 | browserWebSocketUrl, 22 | authenticationScriptPath 23 | } = this.config; 24 | 25 | if (!this.authenticationScript) { 26 | this.loadAuthenticationScript(authenticationScriptPath); 27 | } 28 | 29 | if (this.authenticationScript) { 30 | const browser = await puppeteer.connect({ 31 | browserWSEndpoint: browserWebSocketUrl 32 | }); 33 | 34 | try { 35 | this.logger.print(`${emoji.get('robot_face')} Running authentication script...\n`); 36 | 37 | await this.authenticationScript({ url, browser }); 38 | } catch (error) { 39 | throw new Error(`Running authentication script failed\n\n${error}`); 40 | } finally { 41 | browser.disconnect(); 42 | } 43 | } 44 | } 45 | 46 | private loadAuthenticationScript(authenticationScriptPath: string) { 47 | const absoluteAuthenticationScriptPath = path.resolve(process.cwd(), authenticationScriptPath); 48 | const isAuthenticationScriptPathExists = fs.existsSync(absoluteAuthenticationScriptPath); 49 | 50 | if (!isAuthenticationScriptPathExists) { 51 | throw new Error(`Authentication script path "${authenticationScriptPath}" does not exist`); 52 | } 53 | 54 | const authenticationScriptFileExtension = path.extname(absoluteAuthenticationScriptPath); 55 | 56 | if (authenticationScriptFileExtension === TYPESCRIPT_FILE_EXTENSION) { 57 | require('ts-node').register({ 58 | skipProject: true, 59 | transpileOnly: true, 60 | compilerOptions: { 61 | allowJs: true, 62 | module: 'CommonJS' 63 | } 64 | }); 65 | } 66 | 67 | this.authenticationScript = require(absoluteAuthenticationScriptPath).default; 68 | 69 | if (this.authenticationScript === undefined) { 70 | throw new Error('The contents of the authentication script file were not found. \n\nSee the examples of authentication script files in the repository of the project @perfectum/cli.'); 71 | } 72 | } 73 | } 74 | 75 | export default Authenticator; 76 | -------------------------------------------------------------------------------- /packages/synthetic/src/authenticator/types.ts: -------------------------------------------------------------------------------- 1 | import { Browser } from 'puppeteer'; 2 | 3 | export type AuthenticatorConfig = { 4 | browserWebSocketUrl: string; 5 | authenticationScriptPath: string; 6 | } 7 | 8 | export type AuthenticationScriptArguments = { 9 | url: string; 10 | browser: Browser; 11 | }; 12 | 13 | export type AuthenticationScript = (args: AuthenticationScriptArguments) => Promise; 14 | -------------------------------------------------------------------------------- /packages/synthetic/src/browser/index.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import deepmerge from 'deepmerge'; 3 | import { launch as launchBrowser } from 'chrome-launcher'; 4 | import { BrowserConfig, BrowserMetadata, } from './types'; 5 | 6 | class Browser { 7 | private config: BrowserConfig; 8 | 9 | constructor(initialBrowserConfig?: BrowserConfig) { 10 | const defaultBrowserConfig = { 11 | chromeFlags: ['--headless', '--no-sandbox'] 12 | }; 13 | 14 | const browserConfig = initialBrowserConfig 15 | ? deepmerge(defaultBrowserConfig, initialBrowserConfig) 16 | : defaultBrowserConfig; 17 | 18 | this.config = browserConfig; 19 | } 20 | 21 | async run() { 22 | try { 23 | const browser = await launchBrowser(this.config); 24 | 25 | const stopBrowserProcessCallback = async () => await browser.kill(); 26 | 27 | const browserPort = browser.port; 28 | const browserMetadata = await this.getBrowserMetadata(browserPort); 29 | const browserWebSocketUrl = browserMetadata?.webSocketDebuggerUrl; 30 | 31 | if (!browserWebSocketUrl) { 32 | throw 'WebSocket debugger url was not found'; 33 | } 34 | 35 | return { 36 | browserPort, 37 | browserWebSocketUrl, 38 | stopBrowserProcessCallback 39 | }; 40 | } catch (error) { 41 | throw new Error(`Starting browser failed\n\n${error}`); 42 | } 43 | } 44 | 45 | private async getBrowserMetadata(port: number) { 46 | const browserMetadataResponse = await fetch(`http://localhost:${port}/json/version`); 47 | const browserMetadata = await browserMetadataResponse.json() as BrowserMetadata; 48 | 49 | return browserMetadata; 50 | } 51 | } 52 | 53 | export default Browser; 54 | -------------------------------------------------------------------------------- /packages/synthetic/src/browser/types.ts: -------------------------------------------------------------------------------- 1 | export type BrowserConfig = Record; 2 | 3 | export type BrowserMetadata = { 4 | "Browser": string; 5 | "User-Agent": string; 6 | "V8-Version": string; 7 | "WebKit-Version": string; 8 | "Protocol-Version": string; 9 | "webSocketDebuggerUrl": string; 10 | } 11 | -------------------------------------------------------------------------------- /packages/synthetic/src/builder/index.ts: -------------------------------------------------------------------------------- 1 | import emoji from 'node-emoji'; 2 | import { runCommandProcessAndWaitForComplete } from '../utils/command-runner'; 3 | import { BuilderConfig } from './types'; 4 | import Logger from '../logger'; 5 | 6 | class Builder { 7 | private config: BuilderConfig; 8 | private logger: Logger; 9 | 10 | constructor(initialConfig: BuilderConfig, logger: Logger) { 11 | this.config = initialConfig; 12 | this.logger = logger; 13 | } 14 | 15 | async run() { 16 | const { 17 | buildProjectCommand, 18 | buildProjectTimeout, 19 | commandExecutionContextPath, 20 | buildProjectCompleteStringPattern 21 | } = this.config; 22 | 23 | const logger = this.logger; 24 | 25 | if (!buildProjectCommand) { 26 | throw new Error('Missed the "buildProjectCommand" property'); 27 | } 28 | 29 | if (!buildProjectCompleteStringPattern) { 30 | throw new Error('Missed the "buildProjectCompleteStringPattern" property'); 31 | } 32 | 33 | logger.print(`${emoji.get('package')} Building project...`); 34 | 35 | const stopBuilderProcessCallback = await runCommandProcessAndWaitForComplete( 36 | logger, 37 | buildProjectCommand, 38 | buildProjectTimeout, 39 | buildProjectCompleteStringPattern, 40 | commandExecutionContextPath, 41 | ); 42 | 43 | return stopBuilderProcessCallback; 44 | } 45 | } 46 | 47 | export default Builder; 48 | -------------------------------------------------------------------------------- /packages/synthetic/src/builder/types.ts: -------------------------------------------------------------------------------- 1 | export type BuilderConfig = { 2 | buildProjectCommand?: string; 3 | buildProjectTimeout?: number; 4 | buildProjectCompleteStringPattern?: string; 5 | commandExecutionContextPath?: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/synthetic/src/index.ts: -------------------------------------------------------------------------------- 1 | import emoji from 'node-emoji'; 2 | import Logger from './logger'; 3 | import Auditor from './auditor'; 4 | import Browser from './browser'; 5 | import Builder from './builder'; 6 | import Starter from './starter'; 7 | import Reporter from './reporter'; 8 | import Authenticator from './authenticator'; 9 | import { BrowserConfig } from './browser/types'; 10 | import { BuilderConfig } from './builder/types'; 11 | import { StarterConfig } from './starter/types'; 12 | import { AuditConfig } from './auditor/configs/types'; 13 | import { AuthenticatorConfig } from './authenticator/types'; 14 | import { validatePerformanceConfig } from './utils/config-validators'; 15 | import { 16 | StopProcessCallbackQueue, 17 | SyntheticPerformanceBudget, 18 | SyntheticPerformanceConfig 19 | } from './types'; 20 | export { SyntheticPerformanceConfig } from './types'; 21 | 22 | class SyntheticPerformance { 23 | private config: SyntheticPerformanceConfig; 24 | private logger: Logger; 25 | private auditor: Auditor | null = null; 26 | private reporter: Reporter; 27 | private authenticator: Authenticator | null = null; 28 | private stopProcessCallbackQueue: StopProcessCallbackQueue = []; 29 | 30 | constructor(config: SyntheticPerformanceConfig) { 31 | this.config = config; 32 | this.logger = new Logger({}); 33 | this.reporter = new Reporter(this.logger, config.reporterConfig); 34 | } 35 | 36 | async run() { 37 | try { 38 | const config = this.config; 39 | const logger = this.logger; 40 | const reporter = this.reporter; 41 | 42 | logger.printTitle('Initialization'); 43 | 44 | validatePerformanceConfig(config, logger); 45 | 46 | const { 47 | urls, 48 | budgets, 49 | auditConfig, 50 | browserConfig, 51 | skipBuildProject, 52 | skipStartProject, 53 | numberOfAuditRuns, 54 | startProjectCommand, 55 | startProjectTimeout, 56 | buildProjectCommand, 57 | buildProjectTimeout, 58 | authenticationScriptPath, 59 | commandExecutionContextPath, 60 | startProjectCompleteStringPattern, 61 | buildProjectCompleteStringPattern, 62 | clearReportFilesDirectoryBeforeAudit 63 | } = config; 64 | 65 | const auditNeedBuildProject = Boolean(buildProjectCommand && !skipBuildProject); 66 | const auditNeedStartProject = Boolean(startProjectCommand && !skipStartProject); 67 | 68 | if (auditNeedBuildProject || auditNeedStartProject) { 69 | logger.printTitle('Preparing your project'); 70 | } 71 | 72 | if (auditNeedBuildProject) { 73 | await this.buildProject({ 74 | buildProjectCommand, 75 | buildProjectTimeout, 76 | commandExecutionContextPath, 77 | buildProjectCompleteStringPattern 78 | }, logger); 79 | } 80 | 81 | if (auditNeedStartProject) { 82 | await this.startProject({ 83 | startProjectCommand, 84 | startProjectTimeout, 85 | commandExecutionContextPath, 86 | startProjectCompleteStringPattern 87 | }, logger); 88 | } 89 | 90 | if (clearReportFilesDirectoryBeforeAudit) { 91 | reporter.clearReportFilesDirectory(); 92 | } 93 | 94 | const { 95 | browserPort, 96 | browserWebSocketUrl 97 | } = await this.launchBrowser(browserConfig); 98 | 99 | const urlNamesForPerformanceAudit = Object.keys(urls); 100 | 101 | for (const urlName of urlNamesForPerformanceAudit) { 102 | const url = urls[urlName]; 103 | 104 | logger.printTitle(`Starting performance audit for ${url}`); 105 | 106 | if (authenticationScriptPath) { 107 | await this.authenticateProject(url, { 108 | browserWebSocketUrl, 109 | authenticationScriptPath 110 | }, logger); 111 | } 112 | 113 | await this.auditProject( 114 | url, 115 | urlName, 116 | logger, 117 | reporter, 118 | browserPort, 119 | numberOfAuditRuns, 120 | auditConfig, 121 | budgets 122 | ); 123 | 124 | logger.print(`${emoji.get('tada')} Performance audit completed!\n`); 125 | } 126 | } catch (error) { 127 | this.logger.printError(`\n${emoji.get('rotating_light')} ${error}\n`); 128 | } finally { 129 | await this.stopRunningProcesses(); 130 | } 131 | } 132 | 133 | private async buildProject(config: BuilderConfig, logger: Logger) { 134 | const stopBuilderProcessCallback = await new Builder(config, logger).run(); 135 | 136 | this.stopProcessCallbackQueue.push(stopBuilderProcessCallback); 137 | } 138 | 139 | private async startProject(config: StarterConfig, logger: Logger) { 140 | const stopStarterProcessCallback = await new Starter(config, logger).run(); 141 | 142 | this.stopProcessCallbackQueue.push(stopStarterProcessCallback); 143 | } 144 | 145 | private async authenticateProject(url: string, config: AuthenticatorConfig, logger: Logger) { 146 | if (!this.authenticator) { 147 | this.authenticator = new Authenticator(config, logger); 148 | } 149 | 150 | await this.authenticator.run(url); 151 | } 152 | 153 | private async auditProject( 154 | url: string, 155 | urlName: string, 156 | logger: Logger, 157 | reporter: Reporter, 158 | browserPort: number, 159 | numberOfAuditRuns?: number, 160 | auditConfig?: AuditConfig, 161 | performanceBudgets?: SyntheticPerformanceBudget[] 162 | ) { 163 | if (!this.auditor) { 164 | this.auditor = new Auditor( 165 | logger, 166 | reporter, 167 | browserPort, 168 | auditConfig 169 | ); 170 | } 171 | 172 | const performanceBudgetForCurrentUrl = performanceBudgets && performanceBudgets 173 | .filter(budget => budget.url === urlName) 174 | .shift(); 175 | 176 | await this.auditor.run(url, urlName, numberOfAuditRuns, performanceBudgetForCurrentUrl); 177 | } 178 | 179 | private async launchBrowser(browserConfig?: BrowserConfig) { 180 | const { 181 | browserPort, 182 | browserWebSocketUrl, 183 | stopBrowserProcessCallback 184 | } = await new Browser(browserConfig).run(); 185 | 186 | this.stopProcessCallbackQueue.push(stopBrowserProcessCallback); 187 | 188 | return { 189 | browserPort, 190 | browserWebSocketUrl 191 | }; 192 | } 193 | 194 | private async stopRunningProcesses() { 195 | const stopProcessCallbackQueue = this.stopProcessCallbackQueue; 196 | 197 | if (stopProcessCallbackQueue.length) { 198 | for (const stopProcessCallback of stopProcessCallbackQueue) { 199 | await stopProcessCallback() 200 | } 201 | } 202 | } 203 | } 204 | 205 | export default SyntheticPerformance; 206 | -------------------------------------------------------------------------------- /packages/synthetic/src/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { green, yellow, red, bold } from 'kleur'; 2 | import { LoggerConfig } from './types'; 3 | 4 | export default class Logger { 5 | private config: LoggerConfig; 6 | 7 | constructor(initialConfig: LoggerConfig) { 8 | this.config = initialConfig; 9 | } 10 | 11 | print(message: string) { 12 | console.log(green(message)); 13 | } 14 | 15 | printTitle(message: string) { 16 | console.log(bold().underline().cyan(`\n${message}\n`)); 17 | } 18 | 19 | printWarning(message: string) { 20 | console.log(yellow(message)); 21 | } 22 | 23 | printError(message: string) { 24 | console.log(red(message)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/synthetic/src/logger/types.ts: -------------------------------------------------------------------------------- 1 | export type LoggerConfig = {} 2 | -------------------------------------------------------------------------------- /packages/synthetic/src/reporter/constants.ts: -------------------------------------------------------------------------------- 1 | import { ReportFormats } from './types'; 2 | 3 | export const DEFAULT_REPORT_FORMATS = [ReportFormats.html]; 4 | export const DEFAULT_REPORT_PREFIX_NAME = 'performance-report'; 5 | export const DEFAULT_REPORT_OUTPUT_PATH = './.performance-reports'; 6 | -------------------------------------------------------------------------------- /packages/synthetic/src/reporter/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import emoji from 'node-emoji'; 4 | import ReportGenerator from 'lighthouse/lighthouse-core/report/report-generator.js'; 5 | import { AuditResult } from '../auditor/types'; 6 | import Logger from '../logger'; 7 | import { DeviceTypes } from '../types'; 8 | import { ReporterConfig, ReportFormats } from './types'; 9 | import { 10 | DEFAULT_REPORT_FORMATS, 11 | DEFAULT_REPORT_PREFIX_NAME, 12 | DEFAULT_REPORT_OUTPUT_PATH 13 | } from './constants'; 14 | 15 | class Reporter { 16 | private logger: Logger; 17 | private config: ReporterConfig; 18 | 19 | constructor(logger: Logger, initialReporterConfig?: ReporterConfig) { 20 | this.config = { 21 | reportFormats: initialReporterConfig?.reportFormats || DEFAULT_REPORT_FORMATS, 22 | reportPrefixName: initialReporterConfig?.reportPrefixName || DEFAULT_REPORT_PREFIX_NAME, 23 | reportOutputPath: initialReporterConfig?.reportOutputPath || DEFAULT_REPORT_OUTPUT_PATH 24 | }; 25 | this.logger = logger; 26 | } 27 | 28 | public createReport(auditResult: AuditResult, urlName: string, deviceType: DeviceTypes) { 29 | try { 30 | this.logger.print(`\n${emoji.get('clipboard')} Creating performance report for ${deviceType} devices...\n`); 31 | 32 | this.generateAndSaveReport(auditResult, urlName, deviceType); 33 | } catch (error) { 34 | throw new Error(`Creating performance report for ${deviceType} devices failed\n\n${error}`); 35 | } 36 | } 37 | 38 | private generateAndSaveReport(auditResult: AuditResult, urlName: string, deviceType: DeviceTypes) { 39 | const reportFileName = this.getReportFileName(urlName, deviceType); 40 | const reportFilesDirectoryPath = this.getReportFilesDirectoryPath(); 41 | 42 | if (!this.isReportFilesDirectoryExists(reportFilesDirectoryPath)) { 43 | this.createReportFilesDirectory(reportFilesDirectoryPath); 44 | } 45 | 46 | const reportFilePath = path.join(reportFilesDirectoryPath, reportFileName); 47 | 48 | if (this.config.reportFormats?.includes(ReportFormats.html)) { 49 | this.generateAndSaveReportAsHtml(auditResult, reportFilePath); 50 | } 51 | 52 | if (this.config.reportFormats?.includes(ReportFormats.json)) { 53 | this.generateAndSaveReportAsJson(auditResult, reportFilePath); 54 | } 55 | } 56 | 57 | private generateAndSaveReportAsJson(auditResult: AuditResult, reportFilePath: string) { 58 | fs.writeFileSync(`${reportFilePath}.json`, JSON.stringify(auditResult.lhr)); 59 | } 60 | 61 | private generateAndSaveReportAsHtml(auditResult: AuditResult, reportFilePath: string) { 62 | fs.writeFileSync(`${reportFilePath}.html`, ReportGenerator.generateReportHtml(auditResult.lhr)); 63 | } 64 | 65 | private createReportFilesDirectory(reportFilesDirectoryPath: string) { 66 | fs.mkdirSync(reportFilesDirectoryPath, { recursive: true }); 67 | } 68 | 69 | private isReportFilesDirectoryExists(reportFilesDirectoryPath: string) { 70 | const isReportFilesDirectoryExists = fs.existsSync(reportFilesDirectoryPath); 71 | 72 | return isReportFilesDirectoryExists; 73 | } 74 | 75 | private getReportFileName(urlName: string, deviceType: DeviceTypes) { 76 | const { reportPrefixName } = this.config; 77 | 78 | const replacedUrlName = urlName.replace(/[\W_]+/gi, '-'); 79 | const replacedPrefixName = reportPrefixName.replace(/[\W_]+/gi, '-'); 80 | 81 | const reportFileName = `${replacedPrefixName}-${replacedUrlName}-${deviceType}-${Date.now()}`; 82 | 83 | return reportFileName; 84 | } 85 | 86 | private getReportFilesDirectoryPath() { 87 | const { reportOutputPath } = this.config; 88 | 89 | const reportFilesDirectoryPath = path.join(process.cwd(), reportOutputPath); 90 | 91 | return reportFilesDirectoryPath; 92 | } 93 | 94 | public clearReportFilesDirectory() { 95 | const reportFilesDirectoryPath = this.getReportFilesDirectoryPath(); 96 | 97 | if (!this.isReportFilesDirectoryExists(reportFilesDirectoryPath)) { 98 | return; 99 | } 100 | 101 | const reportFileNames = fs.readdirSync(reportFilesDirectoryPath); 102 | 103 | for (const reportFileName of reportFileNames) { 104 | const reportFilePath = path.join(reportFilesDirectoryPath, reportFileName); 105 | 106 | fs.unlinkSync(reportFilePath); 107 | } 108 | } 109 | } 110 | 111 | export default Reporter; 112 | -------------------------------------------------------------------------------- /packages/synthetic/src/reporter/types.ts: -------------------------------------------------------------------------------- 1 | export type ReporterConfig = { 2 | reportPrefixName: string; 3 | reportOutputPath: string; 4 | reportFormats: ReportFormats[]; 5 | } 6 | 7 | export enum ReportFormats { 8 | html = 'html', 9 | json = 'json' 10 | } 11 | -------------------------------------------------------------------------------- /packages/synthetic/src/starter/index.ts: -------------------------------------------------------------------------------- 1 | import emoji from 'node-emoji'; 2 | import { runCommandProcessAndWaitForComplete } from '../utils/command-runner'; 3 | import Logger from '../logger'; 4 | import { StarterConfig } from './types'; 5 | 6 | class Starter { 7 | private config: StarterConfig; 8 | private logger: Logger; 9 | 10 | constructor(initialConfig: StarterConfig, logger: Logger) { 11 | this.config = initialConfig; 12 | this.logger = logger; 13 | } 14 | 15 | async run() { 16 | const { 17 | startProjectCommand, 18 | startProjectTimeout, 19 | commandExecutionContextPath, 20 | startProjectCompleteStringPattern 21 | } = this.config; 22 | 23 | const logger = this.logger; 24 | 25 | if (!startProjectCommand) { 26 | throw new Error('Missed the "startProjectCommand" property'); 27 | } 28 | 29 | if (!startProjectCompleteStringPattern) { 30 | throw new Error('Missed the "startProjectCompleteStringPattern" property'); 31 | } 32 | 33 | logger.print(`${emoji.get('rocket')} Starting project...`); 34 | 35 | const stopStarterProcessCallback = await runCommandProcessAndWaitForComplete( 36 | logger, 37 | startProjectCommand, 38 | startProjectTimeout, 39 | startProjectCompleteStringPattern, 40 | commandExecutionContextPath 41 | ); 42 | 43 | return stopStarterProcessCallback; 44 | } 45 | } 46 | 47 | export default Starter; 48 | -------------------------------------------------------------------------------- /packages/synthetic/src/starter/types.ts: -------------------------------------------------------------------------------- 1 | export type StarterConfig = { 2 | startProjectCommand?: string; 3 | startProjectTimeout?: number; 4 | startProjectCompleteStringPattern?: string; 5 | commandExecutionContextPath?: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/synthetic/src/types.ts: -------------------------------------------------------------------------------- 1 | import { AuditConfig } from './auditor/configs/types'; 2 | import { BrowserConfig } from './browser/types'; 3 | import { ReporterConfig } from './reporter/types'; 4 | 5 | export type SyntheticPerformanceConfig = { 6 | urls: Record; 7 | budgets?: SyntheticPerformanceBudget[]; 8 | auditConfig?: AuditConfig; 9 | browserConfig?: BrowserConfig; 10 | reporterConfig?: ReporterConfig; 11 | numberOfAuditRuns?: number; 12 | skipBuildProject?: boolean; 13 | skipStartProject?: boolean; 14 | startProjectCommand?: string; 15 | startProjectTimeout?: number; 16 | buildProjectCommand?: string; 17 | buildProjectTimeout?: number; 18 | authenticationScriptPath?: string; 19 | commandExecutionContextPath?: string; 20 | startProjectCompleteStringPattern?: string; 21 | buildProjectCompleteStringPattern?: string; 22 | clearReportFilesDirectoryBeforeAudit?: boolean; 23 | } 24 | 25 | export enum SyntheticPerformanceMetrics { 26 | speedIndex = 'speed-index', 27 | firstPaint = 'first-paint', 28 | firstCpuIdle = 'first-cpu-idle', 29 | resourceSizes = 'resource-sizes', 30 | timeToInteractive = 'interactive', 31 | maxPotentialFid = 'max-potential-fid', 32 | resourceRequests = 'resource-requests', 33 | lighthouseScores = 'lighthouse-scores', 34 | totalBlockingTime = 'total-blocking-time', 35 | firstMeaningfulPaint = 'first-meaningful-paint', 36 | firstContentfulPaint = 'first-contentful-paint', 37 | estimatedInputLatency = 'estimated-input-latency', 38 | largestContentfulPaint = 'largest-contentful-paint' 39 | } 40 | 41 | export enum ResourceTypes { 42 | font = 'font', 43 | total = 'total', 44 | image = 'image', 45 | media = 'media', 46 | other = 'other', 47 | script = 'script', 48 | document = 'document', 49 | stylesheet = 'stylesheet', 50 | thirdParty = 'third-party' 51 | } 52 | 53 | export enum DeviceTypes { 54 | mobile = 'mobile', 55 | desktop = 'desktop' 56 | } 57 | 58 | export enum MetricValueTypes { 59 | target = 'target', 60 | current = 'current' 61 | } 62 | 63 | export type SyntheticPerformanceBudget = { 64 | url: string; 65 | [SyntheticPerformanceMetrics.speedIndex]: Record>; 66 | [SyntheticPerformanceMetrics.firstPaint]: Record>; 67 | [SyntheticPerformanceMetrics.firstCpuIdle]: Record>; 68 | [SyntheticPerformanceMetrics.resourceSizes]: Record>>; 69 | [SyntheticPerformanceMetrics.resourceRequests]: Record>>; 70 | [SyntheticPerformanceMetrics.lighthouseScores]: Record>>; 71 | [SyntheticPerformanceMetrics.maxPotentialFid]: Record>; 72 | [SyntheticPerformanceMetrics.totalBlockingTime]: Record>; 73 | [SyntheticPerformanceMetrics.timeToInteractive]: Record>; 74 | [SyntheticPerformanceMetrics.firstMeaningfulPaint]: Record>; 75 | [SyntheticPerformanceMetrics.firstContentfulPaint]: Record>; 76 | [SyntheticPerformanceMetrics.estimatedInputLatency]: Record>; 77 | [SyntheticPerformanceMetrics.largestContentfulPaint]: Record>; 78 | } 79 | 80 | export type StopProcessCallbackQueue = Array<() => Promise>; 81 | 82 | enum LighthouseCategories { 83 | seo = 'seo', 84 | pwa = 'pwa', 85 | performance = 'performance', 86 | accessibility = 'accessibility', 87 | bestPractices = 'best-practices' 88 | } 89 | -------------------------------------------------------------------------------- /packages/synthetic/src/utils/command-runner.ts: -------------------------------------------------------------------------------- 1 | import emoji from 'node-emoji'; 2 | import killProcessTree from 'tree-kill'; 3 | import { spawn, ChildProcess } from 'child_process'; 4 | import Logger from '../logger'; 5 | 6 | export const runCommandProcessAndWaitForComplete = async ( 7 | logger: Logger, 8 | command: string, 9 | commandTimeout = 5, 10 | commandCompleteStringPattern: string, 11 | commandExecutionContextPath?: string, 12 | ) => { 13 | const commandChildProcess = runChildProcess(command, commandExecutionContextPath); 14 | 15 | const stopCommandProcessCallback = createStopProcessCallback(commandChildProcess.pid, logger); 16 | 17 | const commandProcessData = await waitForCommandCompleteOrTimeout( 18 | logger, 19 | command, 20 | commandTimeout, 21 | commandChildProcess, 22 | commandCompleteStringPattern 23 | ); 24 | 25 | const { 26 | isCommandTimedOut, 27 | isCommandCompleted, 28 | standardErrorCommandData, 29 | standardOutputCommandData 30 | } = commandProcessData; 31 | 32 | if (isCommandCompleted) { 33 | return stopCommandProcessCallback; 34 | } 35 | 36 | await stopCommandProcessCallback(); 37 | 38 | // WEB-379: Organize correct work with exceptions 39 | if (isCommandTimedOut) { 40 | throw new Error(`Command "${command}" timed out`); 41 | } else { 42 | let errorMessage = `Running command "${command}" failed\n\n`; 43 | 44 | if (standardErrorCommandData) { 45 | errorMessage += `${emoji.get('speech_balloon')} STDERR: ${standardErrorCommandData}\n`; 46 | } 47 | 48 | if (standardOutputCommandData) { 49 | errorMessage += `${emoji.get('speech_balloon')} STDOUT: ${standardOutputCommandData}`; 50 | } 51 | 52 | throw new Error(errorMessage); 53 | } 54 | }; 55 | 56 | async function waitForCommandCompleteOrTimeout ( 57 | logger: Logger, 58 | command: string, 59 | commandTimeout: number, 60 | commandChildProcess: ChildProcess, 61 | commandCompleteStringPattern: string 62 | ) { 63 | const commandProcessData = { 64 | standardErrorCommandData: '', 65 | standardOutputCommandData: '', 66 | isCommandTimedOut: false, 67 | isCommandCompleted: false 68 | } 69 | 70 | try { 71 | if (!commandChildProcess.stdout || !commandChildProcess.stderr) { 72 | return commandProcessData; 73 | } 74 | 75 | let cancelCommandTimeoutCallback: () => void; 76 | let successfulCommandCompleteCallback: () => void; 77 | let unsuccessfulCommandCompleteCallback: () => void; 78 | 79 | const commandCompletePromise = new Promise((resolve, reject) => { 80 | successfulCommandCompleteCallback = resolve; 81 | unsuccessfulCommandCompleteCallback = reject; 82 | }); 83 | 84 | const commandTimeoutPromise = new Promise(resolve => { 85 | const commandTimeoutInMilliseconds = commandTimeout * 60 * 1000; 86 | 87 | const commandTimeoutId = setTimeout(() => { 88 | commandProcessData.isCommandTimedOut = true; 89 | 90 | resolve(); 91 | }, commandTimeoutInMilliseconds); 92 | 93 | cancelCommandTimeoutCallback = () => clearTimeout(commandTimeoutId); 94 | }); 95 | 96 | const standardOutputDataListener = (chunk: Buffer) => { 97 | const stringifiedChunk = chunk.toString(); 98 | 99 | commandProcessData.standardOutputCommandData += stringifiedChunk; 100 | commandProcessData.isCommandCompleted = stringifiedChunk.includes(commandCompleteStringPattern); 101 | 102 | if (commandProcessData.isCommandCompleted) { 103 | cancelCommandTimeoutCallback(); 104 | successfulCommandCompleteCallback(); 105 | } 106 | }; 107 | 108 | const standardErrorDataListener = (chunk: Buffer) => { 109 | const stringifiedChunk = chunk.toString(); 110 | 111 | commandProcessData.standardErrorCommandData += stringifiedChunk; 112 | }; 113 | 114 | const processExitListener = (code: number | null) => { 115 | if (code !== null && code !== 0) { 116 | cancelCommandTimeoutCallback(); 117 | unsuccessfulCommandCompleteCallback(); 118 | } 119 | }; 120 | 121 | commandChildProcess.on('exit', processExitListener); 122 | commandChildProcess.stderr.on('data', standardErrorDataListener); 123 | commandChildProcess.stdout.on('data', standardOutputDataListener); 124 | 125 | await Promise.race([commandCompletePromise, commandTimeoutPromise]); 126 | 127 | commandChildProcess.stderr.off('data', standardErrorDataListener); 128 | commandChildProcess.stdout.off('data', standardOutputDataListener); 129 | commandChildProcess.off('exit', processExitListener); 130 | 131 | return commandProcessData; 132 | } catch (error) { 133 | if (error) { 134 | logger.printError(`\n${emoji.get('rotating_light')} ${error}\n`); 135 | } 136 | 137 | return commandProcessData; 138 | } 139 | } 140 | 141 | function runChildProcess(command: string, commandExecutionContextPath?: string) { 142 | return spawn(command, { 143 | shell: true, 144 | stdio: 'pipe', 145 | cwd: commandExecutionContextPath || process.cwd() 146 | }) 147 | } 148 | 149 | function createStopProcessCallback(processIdentifier: number, logger: Logger) { 150 | return () => new Promise(resolve => { 151 | killProcessTree(processIdentifier, error => { 152 | if (error) { 153 | logger.printError(`\n${emoji.get('rotating_light')} Stopping a process with PID ${processIdentifier} failed\n\n${error}\n`); 154 | } 155 | 156 | resolve(); 157 | }); 158 | }); 159 | } 160 | -------------------------------------------------------------------------------- /packages/synthetic/src/utils/config-validators.ts: -------------------------------------------------------------------------------- 1 | import emoji from 'node-emoji'; 2 | import Logger from '../logger'; 3 | import { SyntheticPerformanceConfig, SyntheticPerformanceBudget } from '../types'; 4 | 5 | export const validatePerformanceConfig = (config: SyntheticPerformanceConfig, logger: Logger) => { 6 | logger.print(`${emoji.get('gear')} Checking config...`); 7 | 8 | const { urls, budgets, numberOfAuditRuns } = config; 9 | 10 | validateUrlsProperty(urls) 11 | 12 | if (numberOfAuditRuns !== undefined) { 13 | validateNumberOfAuditRunsProperty(numberOfAuditRuns); 14 | } 15 | 16 | if (budgets !== undefined) { 17 | validateBudgetsProperty(budgets); 18 | } 19 | } 20 | 21 | function validateUrlsProperty(urls: Record | undefined) { 22 | if (urls === undefined) { 23 | throw new Error('Specify "urls" property to run a performance audit'); 24 | } 25 | 26 | if (typeof urls !== 'object' || Array.isArray(urls) || urls === null) { 27 | throw new Error('The "urls" property must be an object'); 28 | } 29 | 30 | if (Object.keys(urls).length === 0) { 31 | throw new Error('Specify at least one URL in the "urls" property to run a performance audit'); 32 | } 33 | 34 | Object.values(urls).forEach(url => { 35 | if (typeof url !== 'string') { 36 | throw new Error('The values of the "urls" property object must be a string'); 37 | } 38 | }) 39 | } 40 | 41 | function validateBudgetsProperty(budgets: SyntheticPerformanceBudget[]) { 42 | if (!Array.isArray(budgets)) { 43 | throw new Error('The "budgets" property must be an array'); 44 | } 45 | 46 | budgets.forEach(budget => { 47 | if (typeof budget.url !== 'string') { 48 | throw new Error('The "url" property in budget must be a string'); 49 | } 50 | }) 51 | } 52 | 53 | function validateNumberOfAuditRunsProperty(numberOfAuditRuns: number) { 54 | if (typeof numberOfAuditRuns !== 'number' || isNaN(numberOfAuditRuns)) { 55 | throw new Error('The "numberOfAuditRuns" property must be a number'); 56 | } 57 | 58 | if (typeof numberOfAuditRuns === 'number' && numberOfAuditRuns < 1) { 59 | throw new Error('The value of the "numberOfAuditRuns" property must not be less than one'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/synthetic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "incremental": true, 6 | "module": "commonjs", 7 | "rootDir": "./src", 8 | "outDir": "./lib", 9 | "declarationDir": "./lib/typings", 10 | "tsBuildInfoFile": "./lib/tsconfig.tsbuildinfo", 11 | "noImplicitAny": false // Set to true after https://github.com/GoogleChrome/lighthouse/issues/1773 12 | }, 13 | "exclude": [ 14 | "**/*.test.ts", 15 | "lib" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /perfectum.json: -------------------------------------------------------------------------------- 1 | { 2 | "synthetic": { 3 | "clearReportFilesDirectoryBeforeAudit": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | "sourceMap": true, /* Generates corresponding '.map' file. */ 12 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationDir": "./", /* Directory for generated '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | // "outDir": "./", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true, /* Enable all strict type-checking options. */ 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | 64 | /* Advanced Options */ 65 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 66 | } 67 | } 68 | --------------------------------------------------------------------------------