├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.yaml ├── package-lock.json ├── package.json ├── public ├── app.js ├── bitly.mjs ├── dashboard.html ├── gauge-element.js ├── img │ ├── CRUX-screenshot.png │ ├── LH-screenshot.png │ ├── PPTR-screenshot.png │ ├── PSI-screenshot.png │ ├── SS-screenshot.png │ ├── TMS-screenshot.png │ ├── WPT-screenshot.png │ ├── ic_arrow_drop_down_circle_black_24px.svg │ ├── ic_cast_connected_white_24px.svg │ ├── ic_cast_white_24px.svg │ ├── ic_check_circle_white_24px.svg │ ├── ic_check_white_24px.svg │ ├── ic_close_black_24px.svg │ ├── ic_content_copy_black_24px.svg │ ├── ic_open_in_new_black_24px.svg │ ├── ic_remove_red_eye_white_24px.svg │ ├── ic_share_white_24px.svg │ ├── lighthouse-logo.png │ ├── lighthouse-logo_no-light.png │ ├── oval.svg │ ├── pptr-icon.png │ ├── tool-impact-calculator.svg │ ├── tool-lighthouse.svg │ ├── tool-psi.svg │ ├── tool-speed-scorecard.svg │ ├── tool-testmysite.png │ ├── tool-testmysite.svg │ └── tool-webpagetest.svg ├── index.html ├── presentation.js ├── receiver.html ├── render.js ├── styles.css └── tools.mjs ├── server.mjs ├── tools ├── lighthouse.mjs ├── psi.mjs ├── tms.mjs └── wpt.mjs └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .gitignore 3 | .dockerignore 4 | Dockerfile* 5 | *-debug.log 6 | *-error.log 7 | .git 8 | .hg 9 | .svn 10 | README.md 11 | report.* 12 | tmp/ 13 | docker_*.sh 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // start with google standard style 3 | // https://github.com/google/eslint-config-google/blob/master/index.js 4 | "extends": ["eslint:recommended", "google"], 5 | "env": { 6 | "node": true, 7 | "es6": true, 8 | "browser": true 9 | }, 10 | // "settings": { 11 | // "import/resolver": { 12 | // node: { extensions: ['.js', '.mjs'] } 13 | // } 14 | // }, 15 | "parserOptions": { 16 | "ecmaVersion": 8, 17 | "ecmaFeatures": { 18 | // "jsx": false, 19 | // "experimentalObjectRestSpread": false 20 | }, 21 | "sourceType": "module" 22 | }, 23 | "rules": { 24 | // 'import/extensions': ['error', 'always', { 25 | // js: 'never', 26 | // mjs: 'never', 27 | // }], 28 | // 2 == error, 1 == warning, 0 == off 29 | "indent": [2, 2, { 30 | "SwitchCase": 1, 31 | "VariableDeclarator": 2 32 | }], 33 | "max-len": [2, 100, { 34 | "ignoreComments": true, 35 | "ignoreUrls": true, 36 | "tabWidth": 2 37 | }], 38 | "no-empty": [2, { 39 | "allowEmptyCatch": true 40 | }], 41 | "no-implicit-coercion": [2, { 42 | "boolean": false, 43 | "number": true, 44 | "string": true 45 | }], 46 | "no-unused-expressions": [2, { 47 | "allowShortCircuit": true, 48 | "allowTernary": false 49 | }], 50 | "no-unused-vars": [2, { 51 | "vars": "all", 52 | "args": "after-used", 53 | "argsIgnorePattern": "(^reject$|^_$)", 54 | "varsIgnorePattern": "(^_$)" 55 | }], 56 | "quotes": [2, "single"], 57 | "strict": [2, "global"], 58 | "prefer-const": 2, 59 | 60 | // Disabled rules 61 | // "require-jsdoc": 0, 62 | "valid-jsdoc": 0, 63 | // "comma-dangle": 0, 64 | "arrow-parens": 0, 65 | "no-console": 0 66 | } 67 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *-error.log 4 | tmp/ 5 | serviceAccount.json 6 | bitlyAccount.json 7 | runs.txt 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-slim 2 | 3 | LABEL maintainer="Eric Bidelman " 4 | 5 | # Run this like so: 6 | # docker run -i --rm --cap-add=SYS_ADMIN \ 7 | # --name puppeteer-chrome puppeteer-chrome-linux \ 8 | # node -e "`cat yourscript.js`" 9 | # 10 | # or run `yarn serve` to start the webservice version. 11 | # 12 | 13 | # # Manually install missing shared libs for Chromium. 14 | # RUN apt-get update && \ 15 | # apt-get install -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \ 16 | # libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \ 17 | # libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \ 18 | # libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \ 19 | # ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget 20 | 21 | # See https://crbug.com/795759 22 | RUN apt-get update && apt-get install -yq libgconf-2-4 23 | 24 | # Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others) 25 | # Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer 26 | # installs, work. 27 | # https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-unstable 28 | RUN apt-get update && apt-get install -y wget --no-install-recommends \ 29 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 30 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 31 | && apt-get update \ 32 | && apt-get install -y google-chrome-unstable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst ttf-freefont \ 33 | --no-install-recommends \ 34 | && rm -rf /var/lib/apt/lists/* \ 35 | && apt-get purge --auto-remove -y curl \ 36 | && rm -rf /src/*.deb 37 | 38 | ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.1/dumb-init_1.2.1_amd64 /usr/local/bin/dumb-init 39 | RUN chmod +x /usr/local/bin/dumb-init 40 | 41 | COPY ./tools /app/tools 42 | COPY ./public /app/public 43 | #COPY local.conf /etc/fonts/local.conf 44 | COPY server.mjs package.json yarn.lock serviceAccount.json bitlyAccount.json /app/ 45 | 46 | RUN chmod +x /app/server.mjs 47 | 48 | WORKDIR app 49 | RUN yarn --frozen-lockfile --no-cache --production 50 | 51 | # Add pptr user. 52 | RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ 53 | && mkdir -p /home/pptruser/Downloads \ 54 | && mkdir -p /app/tmp \ 55 | && chown -R pptruser:pptruser /home/pptruser \ 56 | && chown -R pptruser:pptruser /app 57 | 58 | # Run user as non privileged. 59 | USER pptruser 60 | 61 | EXPOSE 8080 62 | 63 | ENTRYPOINT ["dumb-init", "--"] 64 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /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 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Google Performance Tools Runner 2 | 3 | Web frontend which allows users to simultaneously run several of Google's performance tools 4 | (Lighthouse, PageSpeed Insights, Webpage Test) against a URL, all at once. 5 | [Puppeteer](https://developers.google.com/web/tools/puppeteer/) is used 6 | to take screenshots of the results from each tool and create an aggregated PDF 7 | of all results. 8 | 9 | screen shot 2018-05-01 at 7 15 10 pm 10 | 11 | 12 | ### Explainer 13 | 14 | > Start the server with `npm start`. 15 | 16 | The frontend (http://localhost:8080) UI displays a list of tools for the user 17 | to select. 18 | 19 | When "Enter" is hit, this fires off a request o the `/run` handler. That handler takes a `url` and 20 | `tools` param. The latter is a "," separated list of tools to run. One of LH, PSI, WPT. 21 | 22 | The response is a JSON array of the tools that were run (e.g. `["LH", "PSI"]`). 23 | 24 | Note: every run of the tool logs the URL that was audited and tools to that were 25 | run to runs.txt. 26 | 27 | **Examples** 28 | 29 | Run Lighthouse and PSI against https://example.com: 30 | 31 | http://localhost:8080/run?url=https://example.com/&tools=LH,PSI 32 | 33 | Run Lighthouse against https://example.com using full chrome: 34 | 35 | http://localhost:8080/run?url=https://example.com/&tools=LH&headless=false 36 | 37 | ### Installation & Setup 38 | 39 | Check the repo and run `npm i`. You'll need Node 8+ support for ES modules to work. 40 | 41 | ### Development 42 | 43 | Start the server in the project root: 44 | 45 | ``` 46 | npm start 47 | ``` 48 | 49 | Lint: 50 | 51 | ``` 52 | npm run lint 53 | ``` 54 | 55 | For development, both `serviceAccount.json` and `bitlyAccount.json` are required. 56 | 57 | - Follow instructions from [https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount) to get serviceAccount.json. 58 | - Create an Bitly account from https://dev.bitly.com/my_apps.html and get access token for `bitlyAccount.json`. 59 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: custom 2 | env: flex 3 | 4 | automatic_scaling: 5 | min_num_instances: 1 6 | max_num_instances: 5 7 | 8 | resources: 9 | cpu: 4 10 | memory_gb: 16 11 | disk_size_gb: 10 12 | 13 | skip_files: 14 | - node_modules/ 15 | - test*.js 16 | - ^(.*/)?.*\.md$ 17 | 18 | # runtime: nodejs8 19 | # service: perftools 20 | # instance_class: F4_1G 21 | # # instance_class: F4 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lighthouse-and-friends", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": "Eric Bidelman ", 6 | "description": "Google Performance Tools Runner", 7 | "main": "server.mjs", 8 | "engines": { 9 | "node": ">=10" 10 | }, 11 | "scripts": { 12 | "start": "node --experimental-modules server.mjs", 13 | "lint": "./node_modules/eslint/bin/eslint.js --ext .js,.mjs .", 14 | "docker:build": "docker build -t webperfsandbox .", 15 | "docker:run": "docker run -it -p 8080:8080 --rm --name webperfsandbox --cap-add=SYS_ADMIN webperfsandbox", 16 | "deploy": "gcloud app deploy . --project perf-sandbox" 17 | }, 18 | "dependencies": { 19 | "@google-cloud/firestore": "^0.18.0", 20 | "del": "^3.0.0", 21 | "express": "^4.16.4", 22 | "firebase-admin": "^6.1.0", 23 | "focus-visible": "^4.1.5", 24 | "lighthouse": "^4.0.0-alpha.0", 25 | "lit-html": "^0.13.0", 26 | "node-fetch": "^2.1.2", 27 | "puppeteer": "^1.10.0" 28 | }, 29 | "devDependencies": { 30 | "eslint": "^5.9.0", 31 | "eslint-config-google": "^0.11.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | /* global gtag */ 2 | 3 | import * as render from './render.js'; 4 | import {runners} from './tools.mjs'; 5 | import {Presentation} from './presentation.js'; 6 | 7 | // Render main HTML before anything else. 8 | const primaryTools = Object.entries(runners).filter(t => t[1].primary); 9 | render.renderToolCards(primaryTools, document.querySelector('#tools .toprow')); 10 | const secondaryTools = Object.entries(runners).filter(t => !t[1].primary); 11 | render.renderToolCards(secondaryTools, document.querySelector('#tools .bottomrow')); 12 | 13 | loadLogos(); 14 | 15 | let selectedTools = []; 16 | const tools = document.querySelectorAll('.tool-container'); 17 | const toolsUsed = document.querySelector('#tools-used'); 18 | const input = document.querySelector('#url'); 19 | const arrow = document.querySelector('.search-arrow'); 20 | const viewAction = document.querySelector('#report-link .view-action'); 21 | const shareAction = document.querySelector('#report-link .share-action'); 22 | const overlay = document.querySelector('.overlay'); 23 | const shareUrlInput = overlay.querySelector('input'); 24 | const copyReportURL = overlay.querySelector('.copy-action img'); 25 | const castIcon = document.querySelector('.cast-icon'); 26 | 27 | const presenter = new Presentation('receiver.html'); 28 | 29 | /** 30 | * Fades in the tool screenshots one by one. 31 | * @return {!Promise} Resolves when the images are loaded. 32 | */ 33 | async function loadLogos() { 34 | const logos = Array.from(document.querySelectorAll('.tool .tool-logo')); 35 | return logos.map(logo => { 36 | return new Promise(resolve => { 37 | logo.addEventListener('load', e => { 38 | e.target.classList.add('loaded'); 39 | resolve(e.target); 40 | }, {once: true}); 41 | }); 42 | }); 43 | } 44 | 45 | /** 46 | * Hides the complete check icons for each tool. 47 | */ 48 | function resetCompletedChecks() { 49 | const checks = document.querySelectorAll('.tool-check'); 50 | Array.from(checks).forEach(check => { 51 | check.classList.remove('done'); 52 | check.dataset.runningTime = 0; 53 | }); 54 | } 55 | 56 | /** 57 | * 58 | * @param {!Array} tools Selected tools to run. 59 | * @param {!HTMLElement} toolsUsed Container to render into. 60 | */ 61 | function removeCompletedChecks(tools) { 62 | render.renderToolRunCompleteIcons(tools, toolsUsed); 63 | resetCompletedChecks(); 64 | } 65 | 66 | /** 67 | * Resets UI elements. 68 | */ 69 | function resetUI() { 70 | Array.from(tools).forEach(tool => tool.classList.remove('selected')); 71 | selectedTools = []; 72 | arrow.classList.remove('disabled'); 73 | document.body.classList.remove('running'); 74 | input.value = null; 75 | } 76 | 77 | /** 78 | * Starts running the selected tools and streams results back as they come. 79 | * @param {!URL} url 80 | * @return {!Promise} Resolves when all tool results are complete with 81 | * the URL for the results PDF. 82 | */ 83 | function streamResults(url) { 84 | return new Promise((resolve, reject) => { 85 | const source = new EventSource(url.href); 86 | 87 | // Start running timers for each tool. 88 | const checks = Array.from(document.querySelectorAll('.tool-check[data-tool]')); 89 | const interval = setInterval(() => { 90 | checks.forEach(check => { 91 | if (!check.classList.contains('done')) { 92 | check.dataset.runningTime = parseInt(check.dataset.runningTime || 0) + 1; 93 | } 94 | }); 95 | }, 1000); 96 | 97 | presenter.send({status: 'started'}); 98 | 99 | source.addEventListener('message', e => { 100 | try { 101 | const msg = JSON.parse(e.data.replace(/"(.*)"/, '$1')); 102 | const tool = runners[msg.tool]; 103 | if (tool) { 104 | const check = document.querySelector(`.tool-check[data-tool="${msg.tool}"]`); 105 | check.classList.add('done'); 106 | render.renderToolReportLink({name: tool.name, resultsUrl: msg.resultsUrl}, check); 107 | presenter.send({tool: msg.tool, resultsUrl: msg.resultsUrl}); 108 | } 109 | if (msg.completed) { 110 | clearInterval(interval); 111 | checks.forEach(check => check.classList.add('done')); 112 | source.close(); 113 | presenter.send({status: 'complete'}); 114 | resolve(msg); 115 | } 116 | if (msg.errors) { 117 | throw new Error(msg.errors); 118 | } 119 | } catch (err) { 120 | source.close(); 121 | reject(err); 122 | } 123 | }); 124 | 125 | // source.addEventListener('open', e => { 126 | // // ga('send', 'event', 'Lighthouse', 'start run'); 127 | // }); 128 | 129 | source.addEventListener('error', e => { 130 | if (e.readyState === EventSource.CLOSED) { 131 | source.close(); 132 | reject(e); 133 | } 134 | }); 135 | }); 136 | } 137 | 138 | /** 139 | * Runs the URL in the selected tools. 140 | * @param {string} url 141 | */ 142 | async function go(url) { 143 | url = url.trim(); 144 | 145 | if (!url.match(/^https?:\/\//)) { 146 | url = `https://${url}`; 147 | input.value = url; 148 | } 149 | 150 | document.body.classList.remove('report'); // Remove report link when run starts. 151 | render.renderToolRunCompleteIcons([], toolsUsed); 152 | 153 | if (!url.length || !input.validity.valid) { 154 | alert('URL is not valid'); 155 | return; 156 | } else if (!selectedTools.length) { 157 | alert('Please select a tool'); 158 | return; 159 | } 160 | 161 | // Reset some UI elements. 162 | removeCompletedChecks(selectedTools); 163 | shareUrlInput.value = null; 164 | 165 | presenter.send({status: 'reset'});// reset receiver UI. 166 | 167 | document.body.classList.add('running'); 168 | arrow.classList.add('disabled'); 169 | 170 | const runURL = new URL('/run', location); 171 | runURL.searchParams.set('url', url); 172 | runURL.searchParams.set('tools', selectedTools); 173 | 174 | gtag('event', 'start', {event_category: 'tool'}); 175 | selectedTools.forEach(tool => { 176 | gtag('event', 'run', { 177 | event_category: 'tool', 178 | event_label: tool, 179 | value: 1, 180 | }); 181 | }); 182 | 183 | try { 184 | const {viewURL} = await streamResults(runURL); 185 | 186 | viewAction.href = viewURL; 187 | document.body.classList.add('report'); 188 | document.body.classList.remove('running'); 189 | 190 | gtag('event', 'complete', {event_category: 'tool'}); 191 | } catch (err) { 192 | alert(`Error while streaming results:\n\n${err}`); 193 | removeCompletedChecks([]); 194 | } 195 | 196 | setTimeout(() => { 197 | resetUI(); 198 | }, 500); 199 | } 200 | 201 | Array.from(tools).forEach(tool => { 202 | tool.addEventListener('click', e => { 203 | e.stopPropagation(); 204 | 205 | if (tool.href && tool.dataset.primary === 'false') { 206 | return; 207 | } 208 | 209 | e.preventDefault(); 210 | 211 | const idx = selectedTools.findIndex(t => tool.dataset.tool === t); 212 | if (idx != -1) { 213 | selectedTools.splice(idx, 1); 214 | tool.classList.remove('selected'); 215 | } else { 216 | selectedTools.push(tool.dataset.tool); 217 | tool.classList.add('selected'); 218 | } 219 | }); 220 | }); 221 | 222 | shareAction.addEventListener('click', async e => { 223 | e.preventDefault(); 224 | 225 | if (!viewAction.href) { 226 | console.warn('Report not generated yet.'); 227 | return; 228 | } 229 | 230 | // Don't re-upload PDF to GCS and re-shorten URL. 231 | if (!shareUrlInput.value) { 232 | const originalText = shareAction.textContent; 233 | shareAction.textContent = 'Sharing...'; 234 | const {shortUrl} = await fetch(`/share?pdf=${viewAction.href}`).then(resp => resp.json()); 235 | shareAction.textContent = originalText; 236 | shareUrlInput.value = shortUrl; 237 | 238 | document.querySelector('#qrcode').src = `https://chart.googleapis.com/chart?chs=425x425&cht=qr&chl=${shortUrl}&choe=UTF-8`; 239 | 240 | gtag('event', 'share', {event_category: 'report'}); 241 | } 242 | 243 | overlay.classList.add('show'); 244 | }); 245 | 246 | copyReportURL.addEventListener('click', () => { 247 | navigator.clipboard.writeText(shareUrlInput.value).then(() => { 248 | console.log('URL copied to clipboard.'); 249 | shareUrlInput.select(); 250 | gtag('event', 'copy', {event_category: 'report'}); 251 | }).catch(err => { 252 | console.error('Could not copy text: ', err); 253 | }); 254 | }); 255 | 256 | shareUrlInput.addEventListener('click', e => { 257 | e.target.select(); 258 | }); 259 | 260 | input.addEventListener('keydown', async e => { 261 | if (e.keyCode !== 13 || document.body.classList.contains('running')) { 262 | return; 263 | } 264 | await go(e.target.value); 265 | }); 266 | 267 | arrow.addEventListener('click', async e => { 268 | e.stopPropagation(); 269 | await go(input.value); 270 | }); 271 | 272 | /** 273 | * Starts presenting. 274 | */ 275 | async function startCast() { 276 | const id = localStorage.getItem('connectionId'); 277 | try { 278 | const connection = await presenter.start(id); // eslint-disable-line 279 | } catch (err) { 280 | localStorage.removeItem('connectionId'); 281 | console.warn('Try starting the cast again.'); 282 | return; 283 | } 284 | 285 | presenter.presoRequest.addEventListener('connectionavailable', e => { 286 | castIcon.classList.toggle('on'); 287 | localStorage.setItem('connectionId', e.connection.id); 288 | }, {once: true}); 289 | 290 | presenter.onmessage = data => { 291 | console.log('Received message', data.msg); 292 | }; 293 | } 294 | 295 | castIcon.addEventListener('click', e => { 296 | e.preventDefault(); 297 | if (castIcon.classList.contains('on')) { 298 | presenter.stop(); 299 | castIcon.classList.remove('on'); 300 | localStorage.removeItem('connectionId'); 301 | } else { 302 | startCast(); 303 | } 304 | }); 305 | 306 | // Attempt to start/reconnect to receiver app on page load. This will only 307 | // succeed if the user is refreshing the page and a cast session was previously 308 | castIcon.click(); 309 | 310 | document.addEventListener('click', e => { 311 | if (overlay.classList.contains('show') && e.target === overlay) { 312 | overlay.classList.remove('show'); 313 | } 314 | }); 315 | 316 | document.addEventListener('keydown', e => { 317 | if (e.keyCode === 27 && !document.body.classList.contains('running')) { // ESC 318 | resetUI(); 319 | overlay.classList.remove('show'); 320 | return; 321 | } 322 | }); 323 | -------------------------------------------------------------------------------- /public/bitly.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'fs'; 18 | import url from 'url'; 19 | const {URL} = url; 20 | import fetch from 'node-fetch'; 21 | 22 | const ENDPOINT = 'https://api-ssl.bitly.com'; 23 | 24 | let accessToken; 25 | try { 26 | const json = JSON.parse(fs.readFileSync('./bitlyAccount.json')); 27 | accessToken = json.accessToken; 28 | } catch (err) { 29 | console.error('Missing bitlyAccount.json containing an accessToken property.'); 30 | } 31 | 32 | /** 33 | * Shortens a URL using bitly. 34 | * @param {string} url URL to shorten. 35 | * @return {string} Shortened URL. 36 | */ 37 | async function shorten(url) { 38 | if (!accessToken) { 39 | console.warn('No bit.ly access token available. Not shortening link.'); 40 | return url; 41 | } 42 | 43 | const fetchUrl = new URL(`${ENDPOINT}/v3/shorten`); 44 | fetchUrl.searchParams.set('access_token', accessToken); 45 | fetchUrl.searchParams.set('longUrl', url); 46 | 47 | const resp = await fetch(fetchUrl.href); 48 | if (resp.status !== 200) { 49 | console.warn('Error from bit.ly API. Not shortening link.'); 50 | console.info(resp); 51 | return url; 52 | } 53 | const json = await resp.json(); 54 | return json.data; 55 | } 56 | 57 | export {shorten}; 58 | -------------------------------------------------------------------------------- /public/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lighthouse & Friends 5 | 6 | 7 | 8 | 9 | 10 | 11 | 74 | 75 | 76 | 77 |
78 |
79 |

Median Lighthouse scores:

80 |
81 |
82 |
83 |

Average Lighthouse scores:

84 |
85 |
86 |
87 |

Runs ():

88 |
    89 |
    90 |
    91 | 92 | 93 | 94 | 95 | 262 | 263 | 264 | -------------------------------------------------------------------------------- /public/gauge-element.js: -------------------------------------------------------------------------------- 1 | /* global Util */ 2 | 3 | import './lighthouse/lighthouse-core/report/html/renderer/util.js'; 4 | 5 | const tmpl = document.createElement('template'); 6 | tmpl.innerHTML = ` 7 | 92 |
    93 | 94 | 95 | 97 | 98 |

    99 |

    100 |
    101 | `; 102 | 103 | /* eslint-disable require-jsdoc */ 104 | class GaugeElement extends HTMLElement { 105 | static get observedAttributes() { 106 | return ['score', 'label']; 107 | } 108 | 109 | constructor() { 110 | super(); 111 | const shadowRoot = this.attachShadow({mode: 'open'}); 112 | shadowRoot.appendChild(tmpl.content.cloneNode(true)); 113 | } 114 | 115 | connectedCallback() { 116 | this.wrapper = this.shadowRoot.querySelector('.lh-gauge__wrapper'); 117 | // this.labelEl = this.shadowRoot.querySelector('.lh-gauge__label'); 118 | this.gaugeEl = this.shadowRoot.querySelector('.lh-gauge__percentage'); 119 | this.update(); // TODO: race condition here with score attr being ready via lit and this.score access in update() 120 | } 121 | 122 | get score() { 123 | return parseFloat(this.getAttribute('score')); 124 | } 125 | 126 | set score(val) { 127 | // Reflect the value of `score` as an attribute. 128 | this.setAttribute('score', val); 129 | this.update(); 130 | } 131 | 132 | get label() { 133 | return this.textContent.trim(); 134 | } 135 | 136 | set label(val) { 137 | // Reflect the value of `label` as an attribute. 138 | if (val) { 139 | this.textContent = val.trim(); 140 | } 141 | this.update(); 142 | } 143 | 144 | update() { 145 | // Wait a raf so lit-html rendering has time to setup attributes on custom element. 146 | requestAnimationFrame(() => { 147 | const score = Math.round(this.score * 100); 148 | this.gaugeEl.textContent = score; 149 | this.wrapper.className = 'lh-gauge__wrapper'; 150 | // 329 is ~= 2 * Math.PI * gauge radius (53) 151 | // https://codepen.io/xgad/post/svg-radial-progress-meters 152 | const arc = this.shadowRoot.querySelector('.lh-gauge-arc'); 153 | arc.style.strokeDasharray = `${this.score * 329} 329`; 154 | this.wrapper.classList.add(`lh-gauge__wrapper--${Util.calculateRating(this.score)}`); 155 | }); 156 | } 157 | } 158 | /* eslint-enable require-jsdoc */ 159 | 160 | customElements.define('gauge-element', GaugeElement); 161 | -------------------------------------------------------------------------------- /public/img/CRUX-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/CRUX-screenshot.png -------------------------------------------------------------------------------- /public/img/LH-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/LH-screenshot.png -------------------------------------------------------------------------------- /public/img/PPTR-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/PPTR-screenshot.png -------------------------------------------------------------------------------- /public/img/PSI-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/PSI-screenshot.png -------------------------------------------------------------------------------- /public/img/SS-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/SS-screenshot.png -------------------------------------------------------------------------------- /public/img/TMS-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/TMS-screenshot.png -------------------------------------------------------------------------------- /public/img/WPT-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/WPT-screenshot.png -------------------------------------------------------------------------------- /public/img/ic_arrow_drop_down_circle_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/ic_cast_connected_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/ic_cast_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/ic_check_circle_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/ic_check_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/ic_close_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/ic_content_copy_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/ic_open_in_new_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/ic_remove_red_eye_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/ic_share_white_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/lighthouse-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/lighthouse-logo.png -------------------------------------------------------------------------------- /public/img/lighthouse-logo_no-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/lighthouse-logo_no-light.png -------------------------------------------------------------------------------- /public/img/oval.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/img/pptr-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/pptr-icon.png -------------------------------------------------------------------------------- /public/img/tool-impact-calculator.svg: -------------------------------------------------------------------------------- 1 | icon_impact-calculator_grey_256px -------------------------------------------------------------------------------- /public/img/tool-lighthouse.svg: -------------------------------------------------------------------------------- 1 | icon_lighthouse_256px 2 | -------------------------------------------------------------------------------- /public/img/tool-psi.svg: -------------------------------------------------------------------------------- 1 | icon_pagespeed-insights_256px 2 | -------------------------------------------------------------------------------- /public/img/tool-speed-scorecard.svg: -------------------------------------------------------------------------------- 1 | icon_speed-scorecard_grey_256px -------------------------------------------------------------------------------- /public/img/tool-testmysite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/perftools-runner/38729f93ede299985d23ab358d93e29cfa2bdd15/public/img/tool-testmysite.png -------------------------------------------------------------------------------- /public/img/tool-testmysite.svg: -------------------------------------------------------------------------------- 1 | tool-testmysite 2 | -------------------------------------------------------------------------------- /public/img/tool-webpagetest.svg: -------------------------------------------------------------------------------- 1 | icon_webpagetest_256px 2 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lighthouse & Friends 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
    22 | 25 |
    26 | Select the tools you want to run. Then, enter a url to start auditing the page. 27 |
    28 |
    29 |
    30 | 31 |
    32 |
    33 |
    34 |
    35 | 36 |
    37 |
    38 | 39 |
    40 |
    41 |
    42 |
    43 | 47 |
    48 | 49 |
    50 | 51 |
    52 |
    53 |
    54 |
    55 | 56 | 59 |
    60 |
    61 | 62 |
    63 |
    64 |

    Write down this permalink:

    65 |
    66 | 67 |
    68 | 69 |

    Or, scan this QR code for later:

    70 |
    71 | 72 |
    73 |
    74 |
    75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /public/presentation.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable require-jsdoc */ 2 | 3 | class Presentation { 4 | constructor(page) { 5 | this.page = page; 6 | this.connection = null; 7 | 8 | this.presoRequest = new PresentationRequest(this.page); 9 | // Make this presentation the default one when using the "Cast" browser menu. 10 | navigator.presentation.defaultRequest = this.presoRequest; 11 | 12 | // window.addEventListener('beforeunload', e => { 13 | // this.connection && this.connection.terminate(); 14 | // }); 15 | } 16 | 17 | async start(id = null) { 18 | try { 19 | this.connection = id ? await this.presoRequest.reconnect(id) : 20 | await this.presoRequest.start(); 21 | console.log('Connected to ' + this.connection.url + ', id: ' + this.connection.id); 22 | 23 | this.connection.onmessage = this.onMessage.bind(this); 24 | 25 | this.connection.onterminate = () => { 26 | this.connection = null; 27 | }; 28 | } catch (err) { 29 | console.error(err); 30 | throw err; 31 | } 32 | 33 | return this.connection; 34 | } 35 | 36 | stop() { 37 | this.connection && this.connection.terminate(); 38 | } 39 | 40 | send(msg) { 41 | this.connection && this.connection.send(JSON.stringify(msg)); 42 | } 43 | 44 | onMessage(e) { 45 | if (this.onmessage) { 46 | this.onmessage(JSON.parse(e.data)); 47 | } 48 | } 49 | } 50 | 51 | export {Presentation}; 52 | -------------------------------------------------------------------------------- /public/receiver.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lighthouse & Friends 5 | 6 | 7 | 8 | 9 | 10 | 11 | 94 | 95 | 96 | 97 |
    98 |
    99 |

    Enter a URL to test your site!

    100 | 101 |
    👇
    102 |
    103 |

    waiting for results...

    104 | 105 | 106 | 107 |
    108 |
    109 |
    110 | 111 | 143 | 144 |
    145 | 146 | 147 | 254 | 255 | 256 | -------------------------------------------------------------------------------- /public/render.js: -------------------------------------------------------------------------------- 1 | import {html, render} from '../lit-html/lit-html.js'; 2 | import {repeat} from '../lit-html/directives/repeat.js'; 3 | import {unsafeHTML} from '../lit-html/directives/unsafe-html.js'; 4 | import {runners} from './tools.mjs'; 5 | 6 | /** 7 | * @param {string} key 8 | * @param {string} tool 9 | */ 10 | function toolTemplate(key, tool) { 11 | return html` 12 | 14 |
    15 |
    16 | 17 | ${unsafeHTML(tool.name)} 18 | 19 | 20 | 21 |
    22 |
    ${tool.desc}
    23 |
    24 |
    `; 25 | } 26 | 27 | /** 28 | * @param {!Array} tools 29 | * @param {*} container 30 | */ 31 | function renderToolCards(tools, container) { 32 | const row = html`${ 33 | repeat(tools, (item) => item[0], (item, i) => { // eslint-disable-line 34 | const [key, tool] = item; 35 | return toolTemplate(key, tool); 36 | }) 37 | }`; 38 | 39 | render(row, container); 40 | } 41 | 42 | /** 43 | * @param {!Array} tools 44 | * @param {!HTMLElement} container 45 | */ 46 | function renderToolRunCompleteIcons(tools, container) { 47 | const tmpl = html`${ 48 | repeat(tools, (key) => key, (key, i) => { // eslint-disable-line 49 | const tool = runners[key]; 50 | return html`
    51 | ${tool.name} 52 |
    `; 53 | }) 54 | }`; 55 | 56 | render(tmpl, container); 57 | } 58 | 59 | /** 60 | * @param {!Object} tool 61 | * @param {!HTMLElement} container 62 | */ 63 | function renderToolReportLink(tool, container) { 64 | const tmpl = html`${tool.name}`; 66 | render(tmpl, container); 67 | } 68 | 69 | /** 70 | * @param {!Array} categories 71 | */ 72 | function renderGauges(categories) { 73 | return html`${ 74 | repeat(categories, null, (cat, i) => { // eslint-disable-line 75 | return html`${cat.name}`; 76 | }) 77 | }`; 78 | } 79 | 80 | /** 81 | * @param {string} resultsUrl 82 | * @param {!Array} categories 83 | * @param {!HTMLElement} container 84 | */ 85 | function renderLighthouseResultsRow(resultsUrl, lhr, container) { 86 | if (!resultsUrl || !resultsUrl.length) { 87 | render(html``, container); 88 | return; 89 | } 90 | 91 | const tool = runners['LH']; 92 | // 93 | const tmpl = html` 94 |
    95 |
    96 | 97 |

    ${tool.name} results

    98 |
    99 | 100 | ${renderGauges(Object.values(lhr.categories))} 101 | 102 |
    103 | `; 104 | 105 | render(tmpl, container); 106 | } 107 | 108 | /** 109 | * @param {string} resultsUrl 110 | * @param {!HTMLElement} container 111 | */ 112 | function renderPSIResultsRow(resultsUrl, container) { 113 | if (!resultsUrl || !resultsUrl.length) { 114 | render(html``, container); 115 | return; 116 | } 117 | 118 | const tool = runners['PSI']; 119 | const tmpl = html` 120 |
    121 |
    122 | 123 |

    ${tool.name} results

    124 |
    125 |
    126 | 127 | 128 | 129 |
    130 |
    131 | `; 132 | 133 | render(tmpl, container); 134 | } 135 | 136 | /** 137 | * @param {string} resultsUrl 138 | * @param {!HTMLElement} container 139 | */ 140 | function renderWPTResultsRow(resultsUrl, container) { 141 | if (!resultsUrl || !resultsUrl.length) { 142 | render(html``, container); 143 | return; 144 | } 145 | 146 | const tool = runners['WPT']; 147 | const tmpl = html` 148 |
    149 |
    150 | 151 |

    ${tool.name} results

    152 |
    153 |
    154 | 155 | 156 | 157 |
    158 |
    159 | `; 160 | 161 | render(tmpl, container); 162 | } 163 | 164 | export { 165 | renderToolCards, 166 | renderToolRunCompleteIcons, 167 | renderToolReportLink, 168 | renderLighthouseResultsRow, 169 | renderPSIResultsRow, 170 | renderWPTResultsRow, 171 | }; 172 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | :root { 5 | --padding: 16px; 6 | --padding-half: 8px; 7 | --material-blue-grey-50: #ECEFF1; 8 | --material-blue-grey-100: #CFD8DC; 9 | --material-blue-grey-300: #90A4AE; 10 | --material-blue-grey-400: #78909C; 11 | --material-blue-grey-600: #546E7A; 12 | --material-blue-grey-700: #455A64; 13 | --material-blue-grey-800: #37474F; 14 | --material-blue-grey-900: #263238; 15 | --material-red-500: #F44336; 16 | --material-red-600: #E53935; 17 | --material-green-50: #E8F5E9; 18 | --material-green-100: #C8E6C9; 19 | --material-green-200: #A5D6A7; 20 | --material-green-300: #81C784; 21 | --material-green-400: #66BB6A; 22 | --material-green-600: #4CAF50; 23 | --material-green-800: #2E7D32; 24 | --material-green-900: #1B5E20; 25 | --material-green-A100: #B9F6CA; 26 | --material-green-A700: #00C853; 27 | --material-amber-500: #FFC107; 28 | --material-team-A400: #1DE9B6; 29 | 30 | --card-size: 350px; 31 | /* --card-size-scale-factor: 0.6; */ 32 | } 33 | body { 34 | font-family: 'Google Sans', 'Product Sans', sans-serif; 35 | font-size: 16px; 36 | -webkit-font-smoothing: antialiased; 37 | margin: 0; 38 | color: #fff; 39 | line-height: 1.6; 40 | font-weight: 300; 41 | background-color: var(--material-blue-grey-900); 42 | overflow: hidden; 43 | } 44 | [hidden] { 45 | display: none !important; 46 | } 47 | a { 48 | color: var(--material-blue-grey-dark); 49 | text-decoration: none; 50 | } 51 | /* a:hover { 52 | text-decoration: underline; 53 | } */ 54 | h1, h3, h3 { 55 | margin: 0; 56 | font-weight: inherit; 57 | } 58 | /* .round { 59 | shape-outside: circle(); 60 | shape-margin: 16px; 61 | border-radius: 50%; 62 | } */ 63 | .layout { 64 | display: flex; 65 | } 66 | .layout.vertical { 67 | flex-direction: column; 68 | } 69 | .layout.center { 70 | justify-content: center; 71 | } 72 | .layout.center-center { 73 | align-items: center; 74 | justify-content: center; 75 | } 76 | flex { 77 | flex: 1 1 auto; 78 | } 79 | header h1 { 80 | font-size: 40px; 81 | user-select: none; 82 | } 83 | .instructions { 84 | margin: 0; 85 | padding: 0; 86 | font-size: 20px; 87 | user-select: none; 88 | } 89 | main { 90 | height: 100vh; 91 | } 92 | #tools .toprow { 93 | margin: var(--padding) 0 calc(var(--padding) * 3) 0; 94 | flex-shrink: 0; 95 | } 96 | #tools .bottomrow { 97 | border-top: 6px solid var(--material-blue-grey-800); 98 | padding-top: var(--padding); 99 | } 100 | .tool { 101 | width: var(--card-size); 102 | height: var(--card-size); 103 | border: var(--padding-half) solid var(--material-blue-grey-700); 104 | contain: strict; 105 | border-radius: 50%; 106 | overflow: hidden; 107 | cursor: pointer; 108 | user-select: none; 109 | } 110 | .tool-container { 111 | margin: 0 var(--padding) 0 0; 112 | } 113 | .tool-container:last-of-type { 114 | margin-right: 0; 115 | } 116 | .tool-container[data-primary="false"] .tool { 117 | --card-width: 250px; 118 | border-radius: 0; 119 | border-width: calc(var(--padding-half) / 2); 120 | width: var(--card-width); 121 | height: 150px; 122 | } 123 | .tool-container[data-primary="false"] .tool-name { 124 | padding: var(--padding-half); 125 | transform: none; 126 | font-size: 18px; 127 | line-height: 1.3; 128 | } 129 | .tool-container[data-primary="false"][data-tool="SS"] .tool-name { 130 | font-size: 14px; 131 | } 132 | .tool-container[data-primary="false"] .tool-summary { 133 | display: flex; 134 | justify-content: center; 135 | align-items: center; 136 | font-size: 14px; 137 | padding: var(--padding) var(--padding-half); 138 | height: 100%; 139 | z-index: 2; 140 | background: var(--material-blue-grey-900); 141 | } 142 | .tool-container[data-primary="false"] .external-link-icon { 143 | display: initial; 144 | } 145 | :focus:not(.focus-visible) { 146 | outline: none; 147 | } 148 | /* .tool-container:hover::after { 149 | opacity: 1; 150 | } */ 151 | .tool-container.selected { 152 | filter: drop-shadow(0 0 10px black); 153 | } 154 | .tool-container.selected .tool { 155 | border-color: var(--material-amber-500); 156 | /* border-color: var(--material-team-A400); */ 157 | border-width: var(--padding); 158 | } 159 | .tool:hover .tool-name { 160 | opacity: 1; 161 | transform: none; 162 | background: rgba(38, 50, 56, 1); 163 | } 164 | .tool:hover .tool-summary { 165 | opacity: 1; 166 | transform: none; 167 | } 168 | .tool.placeholder { 169 | visibility: hidden; 170 | } 171 | .tool-header { 172 | position: relative; 173 | height: 100%; 174 | } 175 | .tool-name { 176 | background: rgba(38, 50, 56, 0.6); 177 | color: #fff; 178 | padding: var(--padding) 0; 179 | padding-top: 28px; 180 | position: absolute; 181 | top: 0; 182 | left: 0; 183 | width: 100%; 184 | line-height: 1; 185 | transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); 186 | transform: translateY(-5%); 187 | will-change: transform, opacity; 188 | font-size: 20px; 189 | font-weight: 500; 190 | text-transform: uppercase; 191 | text-align: center; 192 | user-select: none; 193 | z-index: 1; 194 | } 195 | .tool-name .external-link-icon { 196 | margin-left: 4px; 197 | width: 18px; 198 | height: 18px; 199 | display: none; 200 | opacity: 0.5; 201 | } 202 | .tool-summary { 203 | padding: calc(var(--padding) * 2); 204 | padding-top: 24px; 205 | overflow: auto; 206 | text-align: center; 207 | height: 50%; 208 | background: linear-gradient(transparent, var(--material-blue-grey-900) 30%, var(--material-blue-grey-900)); 209 | position: absolute; 210 | bottom: 0; 211 | opacity: 0; 212 | transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); 213 | transform: translateY(10%); 214 | will-change: transform, opacity; 215 | } 216 | .tool-logo { 217 | width: 100%; 218 | height: 100%; 219 | object-fit: cover; 220 | object-position: 50% 0%; 221 | opacity: 0; 222 | transition: all 350ms ease-in; 223 | will-change: opacity; 224 | } 225 | .tool-logo.loaded { 226 | opacity: 1; 227 | } 228 | .tool iframe { 229 | width: 800px; 230 | height: 600px; 231 | transform: scale(0.5); 232 | border: none; 233 | pointer-events: none; 234 | } 235 | .overlay { 236 | position: absolute; 237 | top: 0; 238 | left: 0; 239 | height: 100%; 240 | width: 100%; 241 | background-color: rgba(0,0,0,0.8); 242 | visibility: hidden; 243 | pointer-events: none; 244 | z-index: 5; 245 | } 246 | .overlay.show { 247 | visibility: visible; 248 | pointer-events: initial; 249 | } 250 | .overlay .url-section { 251 | --qrcode-width: 450px; 252 | max-width: var(--qrcode-width); 253 | margin: 0; 254 | } 255 | .overlay .url-section input[type="url"] { 256 | border-radius: 0; 257 | margin-bottom: calc(var(--padding) * 2); 258 | } 259 | .overlay .copy-action { 260 | cursor: copy; 261 | border-radius: 0; 262 | } 263 | .overlay .url-section h2 { 264 | margin: 0; 265 | margin-bottom: var(--padding); 266 | user-select: none; 267 | font-weight: 300; 268 | } 269 | #qrcode { 270 | width: var(--qrcode-width); 271 | } 272 | .url-section { 273 | --url-section-height: 70px; 274 | --url-section-border-radius: calc(var(--url-section-height) / 2); 275 | width: 100%; 276 | max-width: 700px; 277 | position: relative; 278 | margin-top: calc(var(--padding) * 1); 279 | margin-bottom: calc(var(--padding) * 1); 280 | /* overflow: hidden; */ 281 | } 282 | #report-link { 283 | margin-top: var(--padding); 284 | opacity: 0; 285 | transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1); 286 | will-change: opacity; 287 | pointer-events: none; 288 | justify-content: space-evenly; 289 | } 290 | /* #report-link a { 291 | color: #fff; 292 | } */ 293 | #report-link .report-action::after { 294 | content: ''; 295 | display: inline-block; 296 | height: 32px; 297 | width: 32px; 298 | margin-left: var(--padding-half); 299 | background-size: contain; 300 | } 301 | .view-action::after { 302 | background: url("/img/ic_remove_red_eye_white_24px.svg") no-repeat 50% 50%; 303 | } 304 | 305 | .share-action::after { 306 | background: url("/img/ic_share_white_24px.svg") no-repeat 50% 50%; 307 | } 308 | body.report #report-link { 309 | opacity: 1; 310 | pointer-events: all; 311 | } 312 | #report-link { 313 | font-size: 28px; 314 | } 315 | #tools-used { 316 | user-select: none; 317 | justify-content: space-evenly; 318 | margin-top: var(--padding); 319 | height: 38px; 320 | } 321 | .tool-check { 322 | display: flex; 323 | align-items: center; 324 | transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1); 325 | will-change: opacity; 326 | font-size: 24px; 327 | position: relative; 328 | pointer-events: none; 329 | } 330 | .tool-check.done { 331 | color: var(--material-team-A400); 332 | pointer-events: all; 333 | } 334 | .running .tool-check:not(.done)::after { 335 | content: attr(data-running-time) 's'; 336 | opacity: 0.6; 337 | line-height: 1; 338 | position: absolute; 339 | left: 0; 340 | top: 0; 341 | height: 100%; 342 | width: 32px; /* width of check icon */ 343 | font-size: 14px; 344 | justify-content: center; 345 | align-items: center; 346 | display: flex; 347 | } 348 | .tool-check.done::before { 349 | background-image: url(img/ic_check_white_24px.svg); 350 | background-color: var(--material-green-600); 351 | background-size: 24px; 352 | } 353 | .tool-check::before { 354 | content: ''; 355 | background: url(img/oval.svg) no-repeat 50% 50%; 356 | /* background-size: contain; */ 357 | background-size: 34px; 358 | padding: var(--padding); 359 | border-radius: 50%; 360 | margin-right: 6px; 361 | transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1); 362 | } 363 | #url { 364 | text-align: center; 365 | } 366 | .url-section input[type="url"] { 367 | /* --shadow: 0 6px 6px 0 rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.08); */ 368 | /* --hover-shadow: 0 12px 12px 0 rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.08); */ 369 | border: none; 370 | height: var(--url-section-height); 371 | width: 100%; 372 | outline: none; 373 | border-radius: var(--url-section-border-radius); 374 | /* box-shadow: var(--shadow); */ 375 | /* transition: box-shadow 200ms cubic-bezier(0.4, 0.0, 0.2, 1); */ 376 | padding: var(--padding) calc(20px + var(--padding)); 377 | padding-right: calc(50px + var(--padding)); 378 | font-family: inherit; 379 | font-size: 32px; 380 | z-index: 1; 381 | } 382 | .url-section input[type="url"]:-webkit-autofill { 383 | box-shadow: 0 0 0px 1000px white inset, var(--shadow); 384 | } 385 | .url-section input[type="url"]:hover:-webkit-autofill { 386 | box-shadow: 0 0 0px 1000px white inset, var(--hover-shadow); 387 | } 388 | .url-section input[type="url"]::-moz-input-placeholder { 389 | color: var(--material-blue-grey-300); 390 | } 391 | .url-section input[type="url"]::-webkit-input-placeholder { 392 | color: var(--material-blue-grey-300); 393 | } 394 | .search-arrow { 395 | background: linear-gradient(to bottom, transparent 60%,#eee); 396 | border-left: 1px solid #ddd; 397 | cursor: pointer; 398 | color: #555; 399 | /* border-bottom-left-radius: var(--url-section-border-radius); */ 400 | border-bottom-right-radius: var(--url-section-border-radius); 401 | font-size: 50px; 402 | height: var(--url-section-height); 403 | position: absolute; 404 | display: flex; 405 | align-items: center; 406 | justify-content: center; 407 | padding: var(--padding); 408 | right: 0; 409 | user-select: none; 410 | z-index: 2; 411 | } 412 | .search-arrow.disabled { 413 | pointer-events: none; 414 | opacity: 0.5; 415 | } 416 | .search-arrow:hover { 417 | box-shadow: -3px 0px 13px rgba(0,0,0,0.2); 418 | } 419 | .tool-container { 420 | position: relative; 421 | } 422 | .tool-container::after { 423 | --logo-height: 60px; 424 | content: ''; 425 | background-position: 50% 50%; 426 | background-repeat: no-repeat; 427 | background-size: contain; 428 | position: absolute; 429 | width: var(--card-size); 430 | height: var(--logo-height); 431 | z-index: 1; 432 | bottom: calc(-1 * var(--logo-height) / 2 + 10px); 433 | /* transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1); */ 434 | /* will-change: opacity; */ 435 | /* opacity: 0; */ 436 | } 437 | .tool-container[data-primary="false"]::after { 438 | --logo-height: 30px; 439 | width: var(--card-width); 440 | /* display: none; */ 441 | } 442 | .tool-container[data-tool="LH"]::after { 443 | background-image: url(img/tool-lighthouse.svg); 444 | } 445 | .tool-container[data-tool="PSI"]::after { 446 | background-image: url(img/tool-psi.svg); 447 | } 448 | .tool-container[data-tool="WPT"]::after { 449 | background-image: url(img/tool-webpagetest.svg); 450 | } 451 | .tool-container[data-tool="SS"]::after { 452 | background-image: url(img/tool-speed-scorecard.svg); 453 | } 454 | .tool-container[data-tool="TMS"]::after { 455 | background-image: url(img/tool-testmysite.svg); 456 | } 457 | .tool-container[data-tool="PPTR"]::after { 458 | background-image: url(img/pptr-icon.png); 459 | } 460 | .tool-container[data-tool="CRUX"]::after { 461 | background-image: url(img/tool-impact-calculator.svg); 462 | } 463 | .progress { 464 | --progress-bar-height: 9px; 465 | position: absolute; 466 | height: var(--progress-bar-height); 467 | display: block; 468 | width: 100%; 469 | background-color: var(--material-green-100); 470 | border-radius: 2px; 471 | margin: 0 auto; 472 | overflow: hidden; 473 | contain: strict; 474 | display: none; 475 | top: calc(var(--url-section-height) - 10px); 476 | clip-path: url(#progress-clip); 477 | } 478 | .running .progress { 479 | display: initial; 480 | } 481 | .progress .indeterminate { 482 | background-color: var(--material-green-600); 483 | } 484 | .progress .indeterminate::before { 485 | content: ''; 486 | position: absolute; 487 | background-color: inherit; 488 | top: 0; 489 | left: 3px; 490 | bottom: 0; 491 | will-change: left, right; 492 | animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite; 493 | } 494 | .progress .indeterminate::after { 495 | content: ''; 496 | position: absolute; 497 | background-color: inherit; 498 | top: 0; 499 | left: 0; 500 | bottom: 0; 501 | will-change: left, right; 502 | animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite; 503 | animation-delay: 1.15s; 504 | } 505 | .cast-icon { 506 | opacity: 0.4; 507 | border: none; 508 | position: absolute; 509 | top: var(--padding); 510 | right: var(--padding); 511 | width: 24px; 512 | height: 24px; 513 | background: url("/img/ic_cast_white_24px.svg") no-repeat 50% 50%; 514 | background-size: 100%; 515 | cursor: pointer; 516 | transition: opacity 200ms ease-in-out; 517 | will-change: opacity; 518 | } 519 | .cast-icon:hover { 520 | opacity: 1; 521 | } 522 | .cast-icon.on { 523 | background-image: url("/img/ic_cast_connected_white_24px.svg"); 524 | } 525 | footer { 526 | margin-top: var(--padding); 527 | } 528 | footer a { 529 | color: var(--material-team-A400); 530 | } 531 | /* TODO: animate transforms instead of left/right */ 532 | @keyframes indeterminate { 533 | 0% {left:-35%;right:100%} 534 | 60% {left:100%;right:-90%} 535 | 100% {left:100%;right:-90%} 536 | } 537 | @keyframes indeterminate-short { 538 | 0% {left:-200%;right:100%} 539 | 60% {left:107%;right:-8%} 540 | 100% {left:107%;right:-8%} 541 | } 542 | -------------------------------------------------------------------------------- /public/tools.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const DEFAULT_SCREENSHOT_VIEWPORT = { 18 | width: 1280, 19 | height: 1024, 20 | deviceScaleFactor: 1, // Can't use 0. See github.com/GoogleChrome/puppeteer/issues/2358. 21 | }; 22 | 23 | const runners = { 24 | LH: { 25 | name: 'Lighthouse', 26 | url: 'https://developers.google.com/web/tools/lighthouse', 27 | desc: `Personalized advice on how to improve the performance, accessibility, 28 | PWA, SEO, and other best practices of your site.`, 29 | // logo: 'img/lighthouse-logo.png', 30 | logo: '/img/tool-lighthouse.svg', 31 | urlInputSelector: '#url', 32 | primary: true, 33 | }, 34 | WPT: { 35 | name: 'WebPageTest', 36 | url: 'https://www.webpagetest.org/easy.php', 37 | desc: `Compare perf of one or more pages in a controlled 38 | lab environment, testing on real devices. Lighthouse is integrated 39 | into WebPageTest.`, 40 | logo: '/img/tool-webpagetest.svg', 41 | urlInputSelector: '#url', 42 | primary: true, 43 | }, 44 | PSI: { 45 | name: 'PageSpeed', 46 | desc: `See field data for your site, alongside suggestions for 47 | common optimizations to improve it.`, 48 | url: 'https://developers.google.com/speed/pagespeed/insights/', 49 | logo: '/img/tool-psi.svg', 50 | urlInputSelector: 'input[name="url"]', 51 | primary: true, 52 | }, 53 | TMS: { 54 | name: 'Test My Site', 55 | url: 'https://testmysite.thinkwithgoogle.com/', 56 | desc: `Diagnose performance across devices and learn about fixes for 57 | improving the experience. Combines WebPageTest and PageSpeed 58 | Insights.`, 59 | urlInputSelector: 'input[name="url-entry-input"]', 60 | primary: false, 61 | }, 62 | SS: { 63 | name: 'Speed Scorecard & Impact Calculator', 64 | desc: `Compare your mobile site speed & revenue opportunity against peers 65 | in over 10 countries. Uses data from Chrome UX Report & Google Analytics.`, 66 | url: 'https://www.thinkwithgoogle.com/feature/mobile/', 67 | primary: false, 68 | }, 69 | PPTR: { 70 | name: 'Puppeteer', 71 | desc: `A high-level Node API to control headless/full 72 | Chrome (or Chromium) over the DevTools Protocol.`, 73 | url: 'https://try-puppeteer.appspot.com', 74 | primary: false, 75 | }, 76 | CRUX: { 77 | name: 'Chrome UX Report', 78 | desc: `The Chrome User Experience Report provides UX metrics for how 79 | real-world Chrome users experience popular destinations on the web.`, 80 | url: 'https://developers.google.com/web/tools/chrome-user-experience-report/', 81 | primary: false, 82 | }, 83 | }; 84 | 85 | export {runners, DEFAULT_SCREENSHOT_VIEWPORT}; 86 | -------------------------------------------------------------------------------- /server.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import del from 'del'; 18 | import fs from 'fs'; 19 | import util from 'util'; 20 | import express from 'express'; 21 | import fetch from 'node-fetch'; 22 | import firebaseAdmin from 'firebase-admin'; 23 | 24 | import puppeteer from 'puppeteer'; 25 | import {runners, DEFAULT_SCREENSHOT_VIEWPORT} from './public/tools.mjs'; 26 | import * as bitly from './public/bitly.mjs'; 27 | 28 | /* eslint-disable no-unused-vars */ 29 | import * as LHTool from './tools/lighthouse.mjs'; 30 | import * as TMSTool from './tools/tms.mjs'; 31 | import * as WPTTool from './tools/wpt.mjs'; 32 | import * as PSITool from './tools/psi.mjs'; 33 | /* eslint-enable no-unused-vars */ 34 | 35 | const CS_BUCKET = 'perf-sandbox.appspot.com'; 36 | 37 | const firebaseApp = firebaseAdmin.initializeApp({ 38 | // credential: firebaseAdmin.credential.applicationDefault(), 39 | credential: firebaseAdmin.credential.cert( 40 | JSON.parse(fs.readFileSync('./serviceAccount.json'))), 41 | storageBucket: CS_BUCKET, 42 | }); 43 | const db = firebaseApp.firestore(); 44 | db.settings({timestampsInSnapshots: true}); 45 | 46 | const app = express(); 47 | 48 | // Async route handlers are wrapped with this to catch rejected promise errors. 49 | const catchAsyncErrors = (fn) => (req, res, next) => { 50 | Promise.resolve(fn(req, res, next)).catch(next); 51 | }; 52 | 53 | // eslint-disable-next-line 54 | function errorHandler(err, req, res, next) { 55 | console.error(err.message); 56 | // if (res.headersSent) { 57 | // return next(err); 58 | // } 59 | res.write(`data: "${JSON.stringify({ 60 | errors: `Error running your code. ${err}`, 61 | })}"\n\n`); 62 | 63 | res.status(500).end(); // send({errors: `Error running your code. ${err}`}); 64 | } 65 | 66 | /** 67 | * Creates an HTML page from the results and saves it to disk. 68 | * @param {!Array<{tool: string, screenshot: !Buffer}>} results 69 | * @return {string} HTML of page. 70 | */ 71 | function createHTML(results) { 72 | const body = results.map(r => { 73 | const tool = runners[r.tool]; 74 | const resultsLink = r.resultsUrl ? ` 75 | ` : ''; 78 | 79 | return ` 80 |

    ${tool.name} results

    81 |
    82 |
    83 | About this tool: ${tool.desc} 84 | Learn more at ${tool.url} 85 |
    86 | ${resultsLink} 87 |
    88 |
    89 | 90 |
    91 | `; 92 | }).join(''); 93 | 94 | const html = ` 95 | 96 | 97 | 98 | 164 | 165 | 166 |
    167 |
    168 |

    Performance Tools Sandbox Report

    169 |

    ${(new Date()).toLocaleDateString()}

    170 |
    171 |
    172 | 173 |
    174 |
    175 | ${body} 176 | 177 | 178 | `; 179 | 180 | return html; 181 | } 182 | 183 | /** 184 | * Compiles a PDF of the tool screenshot results. 185 | * @param {string} origin Origin of the server. 186 | * @param {!Browser} browser 187 | * @param {string} filename 188 | * @return {!Promise<{buffer: !Buffer, url: string, path: string}>} Created PDF metdata. 189 | */ 190 | async function createPDF(origin, browser, filename) { 191 | const page = await browser.newPage(); 192 | await page.setViewport(DEFAULT_SCREENSHOT_VIEWPORT); 193 | await page.emulateMedia('screen'); 194 | await page.goto(`${origin}/${filename}`, {waitUntil: 'load'}); 195 | 196 | const pdfFilename = `${Date.now()}.${filename.replace('.html', '.pdf')}`; 197 | 198 | const path = `./tmp/${pdfFilename}`; 199 | const buffer = await page.pdf({ 200 | path, 201 | margin: {top: '16px', right: '16px', bottom: '16px', left: '16px'}, 202 | }); 203 | 204 | await page.close(); 205 | 206 | return { 207 | buffer, 208 | url: `${origin}/${pdfFilename}`, 209 | path, 210 | filename: pdfFilename, 211 | }; 212 | } 213 | 214 | /** 215 | * Uploads the PDF to Firebase cloud storage. 216 | * @param {string} pdfURL URL of the PDF to upload to cloud storage. 217 | * @return {string} URL of the file in cloud storage. 218 | */ 219 | async function uploadPDF(pdfURL) { 220 | try { 221 | const bucket = firebaseAdmin.storage().bucket(); 222 | // await file.makePublic(); 223 | // const [metadata] = await file.getMetadata(); 224 | // return metadata.mediaLink; 225 | const parts = pdfURL.split('/'); 226 | const filename = parts[parts.length - 1]; 227 | 228 | // eslint-disable-next-line 229 | const [file, response] = await bucket.upload(pdfURL, { 230 | public: true, 231 | gzip: true, 232 | validation: false, 233 | }); 234 | return `https://storage.googleapis.com/${CS_BUCKET}/${filename}`; 235 | } catch (err) { 236 | console.error('Error uploading PDF:', err); 237 | } 238 | 239 | return null; 240 | } 241 | 242 | /** 243 | * 244 | * @param {string} url 245 | * @param {!Array} tools 246 | * @param {!Arrray} lhr 247 | * @return {DocumentRef} 248 | */ 249 | function logToFirestore(url, tools, lhr) { 250 | const data = { 251 | url, 252 | tools: { 253 | LH: tools.includes('LH'), 254 | PSI: tools.includes('PSI'), 255 | WPT: tools.includes('WPT'), 256 | }, 257 | createdAt: Date.now(), 258 | }; 259 | if (lhr) { 260 | data.lhr = {}; 261 | Object.values(lhr.categories).forEach(cat => { 262 | data.lhr[cat.id] = cat.score; 263 | }); 264 | } 265 | 266 | return db.collection('runs').doc().set(data); 267 | } 268 | 269 | // app.use(function forceSSL(req, res, next) { 270 | // const fromCron = req.get('X-Appengine-Cron'); 271 | // if (!fromCron && req.hostname !== 'localhost' && req.get('X-Forwarded-Proto') === 'http') { 272 | // return res.redirect(`https://${req.hostname}${req.url}`); 273 | // } 274 | // next(); 275 | // }); 276 | 277 | app.use(function addRequestHelpers(req, res, next) { 278 | req.getCurrentUrl = () => `${req.protocol}://${req.get('host')}${req.originalUrl}`; 279 | req.getOrigin = () => { 280 | let protocol = 'https'; 281 | if (req.hostname === 'localhost') { 282 | protocol = 'http'; 283 | } 284 | return `${protocol}://${req.get('host')}`; 285 | }; 286 | next(); 287 | }); 288 | 289 | app.use(express.static('public', {extensions: ['html', 'htm']})); 290 | app.use(express.static('tmp')); 291 | app.use(express.static('node_modules')); 292 | // app.use(function cors(req, res, next) { 293 | // res.set('Access-Control-Allow-Origin', '*'); 294 | // // res.set('Content-Type', 'application/json;charset=utf-8'); 295 | // // res.set('Cache-Control', 'public, max-age=300, s-maxage=600'); 296 | // next(); 297 | // }); 298 | 299 | app.get('/run', catchAsyncErrors(async (req, res) => { 300 | const url = req.query.url; 301 | const origin = req.getOrigin(); 302 | let tools = req.query.tools ? req.query.tools.split(',') : []; 303 | tools = tools.filter(tool => Object.keys(runners).includes(tool)); 304 | const headless = req.query.headless === 'false' ? false : true; 305 | 306 | if (!tools.length) { 307 | throw new Error('Please provide a tool ?tools=[LH,PSI,WPT,TMS,SS].'); 308 | } 309 | 310 | if (!url) { 311 | throw new Error('Please provide a URL.'); 312 | } 313 | 314 | // Clear previous run screenshots. 315 | const paths = await del(['tmp/*']); // eslint-disable-line 316 | 317 | // Send headers for event-stream connection. 318 | res.writeHead(200, { 319 | 'Content-Type': 'text/event-stream', 320 | 'Cache-Control': 'no-cache', 321 | 'Connection': 'keep-alive', 322 | 'X-Accel-Buffering': 'no', // Forces GAE to keep connection open for event streaming. 323 | }); 324 | 325 | // Check if URL exists before kicking off the tools. 326 | // Attempt to fetch the user's URL. 327 | try { 328 | await fetch(url); 329 | } catch (err) { 330 | throw err; 331 | } 332 | 333 | const browser = await puppeteer.launch({ 334 | headless, 335 | // executablePath: '/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary', 336 | // dumpio: true, 337 | args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], 338 | }); 339 | 340 | // If on Mac, use DPR=2 so screenshots are gorgeous. 341 | DEFAULT_SCREENSHOT_VIEWPORT.deviceScaleFactor = process.platform === 'darwin' ? 2 : 1; 342 | 343 | let lhr = null; 344 | try { 345 | const toolsToRun = tools.map(tool => { 346 | console.info(`Started running ${tool}...`); 347 | 348 | return eval(`${tool}Tool`).run(browser, url).then(async results => { 349 | console.info(`Finished running ${tool}.`); 350 | 351 | await util.promisify(fs.writeFile)(`./tmp/${tool}.html`, results.html); 352 | if (results.lhr) { 353 | lhr = results.lhr; 354 | await util.promisify(fs.writeFile)(`./tmp/${tool}.json`, JSON.stringify(results.lhr)); 355 | } 356 | 357 | console.info('Saving screenshot...'); 358 | await util.promisify(fs.writeFile)(`./tmp/${tool}.png`, results.screenshot); 359 | 360 | const resultsUrl = results.resultsUrl || `/${tool}.html`; 361 | 362 | res.write(`data: "${JSON.stringify({tool, resultsUrl})}"\n\n`); 363 | // res.flush(); 364 | 365 | return results; 366 | }); 367 | }); 368 | 369 | const results = await Promise.all(toolsToRun); 370 | 371 | // Save HTML page of results and create PDF from it using Puppeteer. 372 | console.info('Creating PDF...'); 373 | await util.promisify(fs.writeFile)('./tmp/results.html', createHTML(results)); 374 | const pdf = await createPDF(origin, browser, 'results.html'); 375 | console.info('Done.'); 376 | 377 | // Log url to file. 378 | try { 379 | util.promisify(fs.writeFile)('runs.txt', `${url},${tools}\n`, {flag: 'a'}); // async 380 | } catch (err) { 381 | console.warn(err); 382 | } 383 | 384 | // Log run to firestore. 385 | try { 386 | logToFirestore(url, tools, lhr); 387 | } catch (err) { 388 | console.warn(err); 389 | } 390 | 391 | res.write(`data: "${JSON.stringify({ 392 | completed: true, 393 | viewURL: pdf.url, 394 | })}"\n\n`); 395 | 396 | return res.status(200).end(); 397 | } catch (err) { 398 | throw err; 399 | } finally { 400 | await browser.close(); 401 | } 402 | 403 | // res.status(200).send('Done'); 404 | })); 405 | 406 | 407 | app.get('/share', catchAsyncErrors(async (req, res) => { 408 | const pdfURL = req.query.pdf; 409 | 410 | if (!pdfURL) { 411 | throw new Error('PDF url missing.'); 412 | } 413 | 414 | console.info('Uploading PDF to Cloud Storage...'); 415 | const gcsURL = await uploadPDF(pdfURL); 416 | console.info('Done.'); 417 | 418 | console.info('Shortening URL...'); 419 | const bitlyResp = await bitly.shorten(gcsURL); 420 | console.info('Done.'); 421 | 422 | res.status(200).send({ 423 | url: gcsURL, 424 | shortUrl: bitlyResp.url.replace('http:', 'https:'), 425 | }); 426 | })); 427 | 428 | app.use(errorHandler); 429 | 430 | const PORT = process.env.PORT || 8080; 431 | app.listen(PORT, () => { 432 | console.log(`App listening on port ${PORT}. Press Ctrl+C to quit.`); 433 | }); 434 | 435 | // Make sure node server process stops if we get a terminating signal. 436 | /** 437 | * @param {string} sig Signal string. 438 | */ 439 | function processTerminator(sig) { 440 | if (typeof sig === 'string') { 441 | process.exit(1); 442 | } 443 | console.log('%s: Node server stopped.', Date(Date.now())); 444 | } 445 | 446 | [ 447 | 'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGILL', 'SIGTRAP', 'SIGABRT', 'SIGBUS', 448 | 'SIGFPE', 'SIGUSR1', 'SIGSEGV', 'SIGUSR2', 'SIGTERM', 449 | ].forEach(sig => { 450 | process.once(sig, () => processTerminator(sig)); 451 | }); 452 | -------------------------------------------------------------------------------- /tools/lighthouse.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import lighthouse from 'lighthouse'; 18 | // import chromeLauncher from 'chrome-launcher'; 19 | import url from 'url'; 20 | const {URL} = url; 21 | import * as tools from '../public/tools.mjs'; 22 | 23 | /** 24 | * Run Lighthouse. 25 | * @param {!Browser} browser Puppeteer browser instance. 26 | * @param {string} url 27 | * @return {!Promise} 28 | */ 29 | async function run(browser, url) { 30 | const opts = { 31 | chromeFlags: ['--headless'], 32 | // logLevel: 'info', 33 | output: 'html', 34 | port: (new URL(browser.wsEndpoint())).port, 35 | }; 36 | 37 | // const chrome = await chromeLauncher.launch({chromeFlags: opts.chromeFlags}); 38 | // opts.port = chrome.port; 39 | 40 | const lhr = await lighthouse(url, opts, null); 41 | // await chrome.kill(); 42 | 43 | const page = await browser.newPage(); 44 | await page.setViewport(tools.DEFAULT_SCREENSHOT_VIEWPORT); 45 | await page.setContent(lhr.report); 46 | await page.waitFor(1100); // wait for 1s+ so report score gauges have finished their animation. 47 | 48 | const obj = { 49 | tool: 'LH', 50 | screenshot: await page.screenshot({fullPage: true}), 51 | html: await page.content(), 52 | lhr: lhr.lhr, 53 | }; 54 | 55 | await page.close(); 56 | 57 | return obj; 58 | } 59 | 60 | export {run}; 61 | -------------------------------------------------------------------------------- /tools/psi.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as tools from '../public/tools.mjs'; 18 | 19 | const TOOL = tools.runners['PSI']; 20 | 21 | /** 22 | * Run PSI tool using Puppeteer. 23 | * @param {!Browser} 24 | * @param {string} url 25 | * @return {!Promise} 26 | */ 27 | async function run(browser, url) { 28 | const page = await browser.newPage(); 29 | await page.setViewport(tools.DEFAULT_SCREENSHOT_VIEWPORT); 30 | 31 | const resultsUrl = `${TOOL.url}?url=${url}`; 32 | await page.goto(resultsUrl); 33 | // Type the URL into the input. 34 | // await page.goto(TOOL.url); 35 | // await page.waitForSelector(TOOL.urlInputSelector); 36 | // const inputHandle = await page.$(TOOL.urlInputSelector); 37 | // await inputHandle.type(url); 38 | // await inputHandle.press('Enter'); // Run it! 39 | await page.waitForSelector('.result-container .result', {timeout: 10 * 1000}); 40 | 41 | // Expand all zippies. 42 | await page.$$eval('.lh-audit-group', els => els.forEach(el => el.open = true)); 43 | // await page.waitForSelector('.result-group-body', {visible: true}); 44 | 45 | // Reset viewport to full page screenshot captures expanded content. 46 | const docHeight = await page.evaluate('document.body.clientHeight'); 47 | const viewport = Object.assign({}, tools.DEFAULT_SCREENSHOT_VIEWPORT, {height: docHeight}); 48 | await page.setViewport(viewport); 49 | 50 | const obj = { 51 | tool: 'PSI', 52 | screenshot: await page.screenshot({fullPage: true}), 53 | html: await page.content(), 54 | resultsUrl, 55 | }; 56 | 57 | await page.close(); 58 | 59 | return obj; 60 | } 61 | export {run}; 62 | -------------------------------------------------------------------------------- /tools/tms.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as tools from '../public/tools.mjs'; 18 | 19 | const TOOL = tools.runners['TMS']; 20 | 21 | /** 22 | * Run TestMySite tool using Puppeteer. 23 | * @param {!Browser} 24 | * @param {string} url 25 | * @return {!Promise} 26 | */ 27 | async function run(browser, url) { 28 | const page = await browser.newPage(); 29 | await page.setViewport(tools.DEFAULT_SCREENSHOT_VIEWPORT); 30 | 31 | // const ua = await browser.userAgent(); 32 | // await page.setUserAgent(ua.replace('Headless', '')); 33 | 34 | // await page.setRequestInterception(true); 35 | // page.on('request', req => { 36 | // if (req.url().includes('https://www.google.com/recaptcha/api.js') || 37 | // req.url().includes('app.js')) { 38 | // req.abort(); 39 | // return; 40 | // } 41 | // req.continue(); 42 | // }); 43 | 44 | // page.on('domcontentloaded', async e => { 45 | // await page.$$('body script', scripts => { 46 | // scripts[scripts.length - 1].remove(); 47 | // }); 48 | // }); 49 | 50 | // await page.evaluateOnNewDocument(() => { 51 | // document.addEventListener('DOMContentLoaded', e => { 52 | // document.scripts[document.scripts.length - 1].remove(); 53 | // }); 54 | // }); 55 | 56 | // await page.goto(TOOL.url); 57 | // await page.waitForSelector(TOOL.urlInputSelector); 58 | await page.goto(`${TOOL.url}?url=${url}`); // , {waitUntil: 'networkidle2'}); 59 | 60 | // const inputHandle = await page.$(TOOL.urlInputSelector); 61 | // await inputHandle.type(url); 62 | // await inputHandle.press('Enter'); // Run it! 63 | 64 | await page.waitForSelector('.results', {timeout: 60 * 1000}); 65 | await page.waitForSelector('[data-scene="SceneTwo"]', {visible: true}); 66 | // await page.waitFor(10000); 67 | 68 | const obj = { 69 | tool: 'TMS', 70 | screenshot: await page.screenshot({fullPage: true}), 71 | html: await page.content(), 72 | }; 73 | 74 | await page.close(); 75 | 76 | return obj; 77 | } 78 | 79 | export {run}; 80 | -------------------------------------------------------------------------------- /tools/wpt.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import url from 'url'; 18 | const {URL} = url; 19 | import fetch from 'node-fetch'; 20 | import * as tools from '../public/tools.mjs'; 21 | 22 | const API_KEY = '7dce091be3b64d95b7812bb5211ad5a9'; // 'A.04c7244ba25a5d6d717b0343a821aa59'; 23 | // const TOOL = tools.runners['WPT']; 24 | // const WPT_PR_MAP = new Map(); 25 | 26 | /** 27 | * Uses WebPageTest's Rest API to run Lighthouse and score a URL. 28 | * See https://sites.google.com/a/webpagetest.org/docs/advanced-features/webpagetest-restful-apis 29 | * @param {!string} testUrl URL to audit. 30 | * @param {!string=} pingback Optional URL for WPT to ping when result is ready. 31 | * If not provided, WPT redirects to the results page when done. 32 | * @return {!Promise} json response from starting a WPT run. 33 | */ 34 | async function startOnWebpageTest(testUrl, pingback = null) { 35 | const wptUrl = new URL('https://www.webpagetest.org/runtest.php'); 36 | wptUrl.searchParams.set('k', API_KEY); 37 | wptUrl.searchParams.set('f', 'json'); 38 | if (pingback) { 39 | // The test id is passed back with the pingback URL as a "id" query param. 40 | wptUrl.searchParams.set('pingback', pingback); 41 | } 42 | // These emulation settings should match LH settings. 43 | // wptUrl.searchParams.set('location', 'Dulles_Nexus5:Nexus 5 - Chrome Canary.3GFast'); 44 | wptUrl.searchParams.set('location', 'Dulles_MotoG4:MotoG4 - Chrome.3GFast'); 45 | // wptUrl.searchParams.set('mobile', 1); // Emulate mobile (for desktop cases). 46 | // wptUrl.searchParams.set('type', 'lighthouse'); // LH-only run. 47 | 48 | wptUrl.searchParams.set('fvonly', 1); // skips the repeat view and will cut the run time in half 49 | // wptUrl.searchParams.set('video', 1); // include video filmstrips which are one of the most important features 50 | // wptUrl.searchParams.set('timeline', 1); // include main-thread activity and js execution info in the waterfalls 51 | wptUrl.searchParams.set('priority', 0); // top priority, head of queue. 52 | wptUrl.searchParams.set('runs', 1); // number of tests to run. 53 | 54 | // wptUrl.searchParams.set('lighthouse', 1); // include LH results. 55 | wptUrl.searchParams.set('url', testUrl); 56 | 57 | try { 58 | const json = await fetch(wptUrl.href).then(resp => resp.json()); 59 | 60 | const userUrl = json.data.userUrl; 61 | console.info('Started WPT run:', userUrl); 62 | 63 | const waitForRunToFinish = async function(statusUrl) { 64 | return new Promise(async resolve => { 65 | const interval = setInterval(async () => { 66 | const json = await fetch(statusUrl).then(resp => resp.json()); 67 | console.info(json.statusText); 68 | if (json.statusCode === 200) { 69 | clearInterval(interval); 70 | return resolve(userUrl); 71 | } 72 | }, 10 * 1000); // poll every 10 seconds. 73 | }); 74 | }; 75 | 76 | const checkStatusUrl = new URL('https://www.webpagetest.org/testStatus.php'); 77 | checkStatusUrl.searchParams.set('test', json.data.testId); 78 | 79 | return waitForRunToFinish(checkStatusUrl.href); 80 | } catch (err) { 81 | console.error(err); 82 | throw err; 83 | } 84 | } 85 | 86 | /** 87 | * Run WebpageTest tool using Puppeteer. 88 | * @param {!Browser} 89 | * @param {string} url 90 | * @return {!Promise} 91 | */ 92 | async function run(browser, url) { 93 | const resultsUrl = await startOnWebpageTest(url); 94 | 95 | const page = await browser.newPage(); 96 | await page.setViewport(tools.DEFAULT_SCREENSHOT_VIEWPORT); 97 | await page.goto(`${resultsUrl}1/details/`); 98 | 99 | const main = await page.$('#main'); 100 | const screenshot = await main.screenshot(); 101 | // const screenshot = await page.screenshot({fullPage: true}); 102 | 103 | const obj = { 104 | tool: 'WPT', 105 | screenshot, 106 | html: await page.content(), 107 | resultsUrl, 108 | }; 109 | 110 | await page.close(); 111 | 112 | return obj; 113 | } 114 | 115 | export {run}; 116 | --------------------------------------------------------------------------------