├── docs └── img │ ├── spread-sheet-name.png │ ├── datastudio-example.png │ ├── gas-webpagetest-menu.png │ └── spread-sheet-example.png ├── appsscript.json ├── tsconfig.json ├── .circleci └── config.yml ├── src ├── index.ts ├── gas.d.ts ├── updateColumnTitles.ts ├── TimeTrigger.ts ├── setRunTestTimeTriggers.ts ├── getTestResults.ts ├── runTest.ts ├── __tests__ │ ├── Utils.test.ts │ ├── WebPagetest.test.ts │ └── __snapshots__ │ │ └── WebPagetest.test.ts.snap ├── Utils.ts └── WebPagetest.ts ├── scripts └── create.js ├── webpack.config.js ├── .env.example ├── .gitignore ├── package.json └── README.md /docs/img/spread-sheet-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uknmr/gas-webpagetest/HEAD/docs/img/spread-sheet-name.png -------------------------------------------------------------------------------- /docs/img/datastudio-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uknmr/gas-webpagetest/HEAD/docs/img/datastudio-example.png -------------------------------------------------------------------------------- /docs/img/gas-webpagetest-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uknmr/gas-webpagetest/HEAD/docs/img/gas-webpagetest-menu.png -------------------------------------------------------------------------------- /docs/img/spread-sheet-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uknmr/gas-webpagetest/HEAD/docs/img/spread-sheet-example.png -------------------------------------------------------------------------------- /appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Tokyo", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER" 6 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015", "es2016", "es2017"], 4 | "noImplicitReturns": true, 5 | "strictNullChecks": true, 6 | "sourceMap": true, 7 | "target": "es5", 8 | "outDir": "lib" 9 | }, 10 | "compileOnSave": true, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:latest 6 | steps: 7 | - checkout 8 | 9 | # Download and cache dependencies with Yarn 10 | - restore_cache: 11 | name: Restore Yarn Package Cache 12 | keys: 13 | - yarn-packages-{{ checksum "yarn.lock" }} 14 | - run: 15 | name: Install Dependencies 16 | command: yarn install 17 | - save_cache: 18 | name: Save Yarn Package Cache 19 | key: yarn-packages-{{ checksum "yarn.lock" }} 20 | paths: 21 | - ~/.cache/yarn 22 | 23 | # run tests! 24 | - run: yarn test 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { runTest } from './runTest' 2 | import { getTestResults } from './getTestResults' 3 | import { updateColumnTitles } from './updateColumnTitles' 4 | import { setRunTestTimeTriggers } from './setRunTestTimeTriggers' 5 | 6 | function onOpen() { 7 | const menu = [ 8 | { name: 'Run test', functionName: 'runTest' }, 9 | { name: 'Get test results', functionName: 'getTestResults' }, 10 | { name: 'Set run test time triggers', functionName: 'setRunTestTimeTriggers' }, 11 | { name: 'Update column titles', functionName: 'updateColumnTitles' }, 12 | ] 13 | SpreadsheetApp.getActiveSpreadsheet().addMenu('gas-webpagetest', menu) 14 | } 15 | 16 | global.onOpen = onOpen 17 | global.runTest = runTest 18 | global.getTestResults = getTestResults 19 | global.setRunTestTimeTriggers = setRunTestTimeTriggers 20 | global.updateColumnTitles = updateColumnTitles 21 | -------------------------------------------------------------------------------- /src/gas.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | // extends process.env 3 | interface ProcessEnv { 4 | WEBPAGETEST_API_KEY?: string 5 | RUN_TEST_URL?: string 6 | RUN_TEST_INTERVAL?: string 7 | SHEET_NAME?: string 8 | NETWORK_ERROR_REPORT?: string 9 | WEBPAGETEST_OPTIONS_RUNS?: string 10 | WEBPAGETEST_OPTIONS_LOCATION?: string 11 | WEBPAGETEST_OPTIONS_FVONLY?: string 12 | WEBPAGETEST_OPTIONS_VIDEO?: string 13 | WEBPAGETEST_OPTIONS_NO_OPTIMIZATION?: string 14 | WEBPAGETEST_OPTIONS_MOBILE?: string 15 | WEBPAGETEST_OPTIONS_MOBILE_DEVICE?: string 16 | WEBPAGETEST_OPTIONS_LIGHTHOUSE?: string 17 | WEBPAGETEST_OPTIONS_SCRIPT_CODE?: string 18 | } 19 | 20 | // extends global 21 | export interface Global { 22 | runTest: () => void 23 | getTestResults: () => void 24 | setRunTestTimeTriggers: () => void 25 | updateColumnTitles: () => void 26 | onOpen: () => void 27 | } 28 | } 29 | 30 | declare type Options = Partial<{ 31 | location: string 32 | runs: number 33 | fvonly: number 34 | video: number 35 | format: string 36 | noOptimization: number 37 | mobile: number 38 | mobileDevice: string 39 | lighthouse: number 40 | script: string 41 | }> 42 | -------------------------------------------------------------------------------- /src/updateColumnTitles.ts: -------------------------------------------------------------------------------- 1 | import WebPagetest = require('./WebPagetest') 2 | 3 | export const updateColumnTitles = (): void => { 4 | const sheetName = process.env.SHEET_NAME 5 | if (!sheetName) { 6 | throw new Error('should define SHEET_NAME in .env') 7 | } 8 | const activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet() 9 | if (!activeSpreadsheet) { 10 | throw new Error('Not found active spreadsheet') 11 | } 12 | const sheet = activeSpreadsheet.getSheetByName(sheetName) 13 | if (!sheet) { 14 | throw new Error(`Not found sheet by name:${sheetName}`) 15 | } 16 | const firstRange = sheet.getRange(1, 1, 1, 1) 17 | const firstCellValue = firstRange.getValue() 18 | // if 1:1 value is `testId`, update columns 19 | // if 1:1 value is not `testId`, add new empty row and update columns 20 | const FIRST_CELL_VALUE = 'testId' 21 | if (firstCellValue !== FIRST_CELL_VALUE) { 22 | // if have not column title, add new row to first 23 | sheet.insertRowBefore(1) 24 | } 25 | // update column titles 26 | const wpt = new WebPagetest() 27 | const titles = [FIRST_CELL_VALUE].concat(wpt.generateTestResultNames()) 28 | const targetRange = sheet.getRange(1, 1, 1, titles.length) 29 | targetRange.setValues([titles]) 30 | } 31 | -------------------------------------------------------------------------------- /src/TimeTrigger.ts: -------------------------------------------------------------------------------- 1 | export type EveryMinutesType = 1 | 5 | 10 | 15 | 30 2 | 3 | export class TimeTrigger { 4 | /** 5 | * @see https://developers.google.com/apps-script/reference/script/clock-trigger-builder#everyHours(Integer) 6 | */ 7 | public addNewEveryHoursTrigger(functionName: string, hours: number) { 8 | ScriptApp.newTrigger(functionName) 9 | .timeBased() 10 | .everyHours(hours) 11 | .create() 12 | } 13 | 14 | /** 15 | * EveryMinutes's value must be one of 1, 5, 10, 15 or 30. 16 | */ 17 | public canAcceptableAsEveryMinutes(minutes: number): minutes is EveryMinutesType { 18 | return [1, 5, 10, 15, 30].indexOf(minutes) !== -1 19 | } 20 | 21 | /** 22 | * @see https://developers.google.com/apps-script/reference/script/clock-trigger-builder#everyMinutes(Integer) 23 | */ 24 | public addNewEveryMinutesTrigger(functionName: string, minutes: EveryMinutesType) { 25 | ScriptApp.newTrigger(functionName) 26 | .timeBased() 27 | .everyMinutes(minutes) 28 | .create() 29 | } 30 | 31 | public deleteTrigger(functionName: string) { 32 | // delete function that match functionName 33 | const triggers = ScriptApp.getProjectTriggers() 34 | triggers.forEach(trigger => { 35 | if (trigger.getHandlerFunction() === functionName) { 36 | ScriptApp.deleteTrigger(trigger) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /scripts/create.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const fs = require('fs') 3 | const path = require('path') 4 | const spawn = require('cross-spawn') 5 | const rootDir = path.join(__dirname, '..') 6 | 7 | const claspJSONPath = path.join(rootDir, '.clasp.json') 8 | // run clasp create if have not .clasp.json 9 | if (!fs.existsSync(claspJSONPath)) { 10 | const clasp = require.resolve('.bin/clasp') 11 | const [title, parentId] = process.argv.slice(2) 12 | spawn.sync(clasp, ['create', '--title', title, '--parentId', parentId], { 13 | cwd: rootDir, 14 | stdio: 'inherit', 15 | shell: true, 16 | }) 17 | console.log('✓ Create .clasp.json') 18 | } else { 19 | console.log('⚠ .clasp.json is already exists') 20 | } 21 | // modify .clasp.json if it exists 22 | if (fs.existsSync(claspJSONPath)) { 23 | const claspJSON = require(claspJSONPath) 24 | if (!claspJSON['rootDir']) { 25 | claspJSON['rootDir'] = 'dist' 26 | fs.writeFileSync(claspJSONPath, JSON.stringify(claspJSON, null, 4), 'utf-8') 27 | console.log('✓ Add dist directory to .clasp.json') 28 | } else { 29 | console.log('⚠ .clasp.json already has rootDir') 30 | } 31 | } 32 | // cp .env.example .env 33 | if (!fs.existsSync(path.join(rootDir, '.env'))) { 34 | fs.copyFileSync(path.join(rootDir, '.env.example'), path.join(rootDir, '.env')) 35 | console.log('✓ Copy .env.example to .env') 36 | } else { 37 | console.log('⚠ .env is already exists') 38 | } 39 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const Dotenv = require('dotenv-webpack') 3 | const GasPlugin = require('gas-webpack-plugin') 4 | const webpack = require('webpack') 5 | const fs = require('fs') 6 | const dotenv = require('dotenv') 7 | const getWebPagetestCode = () => { 8 | const result = dotenv.config() 9 | if (result.error) { 10 | throw result.error 11 | } 12 | const WEBPAGETEST_OPTIONS_SCRIPT_PATH = result.parsed.WEBPAGETEST_OPTIONS_SCRIPT_PATH 13 | if (!WEBPAGETEST_OPTIONS_SCRIPT_PATH) { 14 | return 15 | } 16 | const SCRIPT_FILE_PATH = path.resolve(__dirname, WEBPAGETEST_OPTIONS_SCRIPT_PATH) 17 | console.log(SCRIPT_FILE_PATH) 18 | if (!fs.existsSync(SCRIPT_FILE_PATH)) { 19 | return 20 | } 21 | return fs.readFileSync(SCRIPT_FILE_PATH, 'utf-8') 22 | } 23 | 24 | module.exports = { 25 | mode: 'none', 26 | entry: { 27 | 'gas-webpagetest': './src/index.ts', 28 | }, 29 | output: { 30 | path: path.resolve(__dirname, 'dist'), 31 | filename: '[name].js', 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.ts$/, 37 | use: 'ts-loader', 38 | }, 39 | ], 40 | }, 41 | resolve: { 42 | extensions: ['.ts', '.js'], 43 | }, 44 | devtool: false, 45 | plugins: [ 46 | new Dotenv(), 47 | new webpack.DefinePlugin({ 48 | 'process.env.WEBPAGETEST_OPTIONS_SCRIPT_CODE': JSON.stringify(getWebPagetestCode()), 49 | }), 50 | new GasPlugin(), 51 | ], 52 | } 53 | -------------------------------------------------------------------------------- /src/setRunTestTimeTriggers.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/modules/es.number.is-integer' 2 | import { TimeTrigger } from './TimeTrigger' 3 | import Utils = require('./Utils') 4 | 5 | export const setRunTestTimeTriggers = (): void => { 6 | const parsedInterval = Utils.parseTimeFormat(process.env.RUN_TEST_INTERVAL) 7 | if (parsedInterval instanceof Error) { 8 | throw new Error(`should define RUN_TEST_INTERVAL with correct format in .env. 9 | Error: ${parsedInterval.message}`) 10 | } 11 | const timeTrigger = new TimeTrigger() 12 | // delete exists time trigger 13 | timeTrigger.deleteTrigger('runTest') 14 | timeTrigger.deleteTrigger('getTestResults') 15 | // set new Time Trigger 16 | if (parsedInterval.type === 'HOUR') { 17 | if (parsedInterval.value <= 0) { 18 | throw new Error(`RUN_TEST_INTERVAL hour must be larger than 0h.`) 19 | } 20 | timeTrigger.addNewEveryHoursTrigger('runTest', parsedInterval.value) 21 | } else if (parsedInterval.type === 'MINUTE') { 22 | if (!timeTrigger.canAcceptableAsEveryMinutes(parsedInterval.value)) { 23 | throw new Error(`RUN_TEST_INTERVAL minutes must be one of 1m, 5m, 10m, 15m or 30m.`) 24 | } 25 | timeTrigger.addNewEveryMinutesTrigger('runTest', parsedInterval.value) 26 | } 27 | // check the result every 10min or 30min 28 | const getTestResultsInterval = (parsedInterval => { 29 | if (parsedInterval.type === 'MINUTE') { 30 | return 10 31 | } else { 32 | return 30 33 | } 34 | })(parsedInterval) 35 | timeTrigger.addNewEveryMinutesTrigger('getTestResults', getTestResultsInterval) 36 | } 37 | -------------------------------------------------------------------------------- /src/getTestResults.ts: -------------------------------------------------------------------------------- 1 | import WebPagetest = require('./WebPagetest') 2 | import Utils = require('./Utils') 3 | 4 | export const getTestResults = () => { 5 | const sheetName = process.env.SHEET_NAME 6 | const enabledNetworkErrorReport = Utils.parseBooleanNumberValue(process.env.NETWORK_ERROR_REPORT) 7 | if (!sheetName) { 8 | throw new Error('should define SHEET_NAME in .env') 9 | } 10 | const activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet() 11 | if (!activeSpreadsheet) { 12 | throw new Error('Not found active spreadsheet') 13 | } 14 | const sheet = activeSpreadsheet.getSheetByName(sheetName) 15 | if (!sheet) { 16 | throw new Error(`Not found sheet by name:${sheetName}`) 17 | } 18 | const lastTestIdRow = Utils.getLastRow(sheet, 'A') 19 | const lastCompletedRow = Utils.getLastRow(sheet, 'B') 20 | Logger.log('lastTestIdRow: %s, lastCompletedRow: %s', lastTestIdRow, lastCompletedRow) 21 | if (lastTestIdRow === lastCompletedRow) { 22 | Logger.log('すべての testId の結果が取得済みです') 23 | return 24 | } 25 | const testIds = sheet 26 | .getRange(`A${lastCompletedRow + 1}:A${lastTestIdRow}`) 27 | .getValues() 28 | .reduce((a, b) => a.concat(b), []) 29 | 30 | if (!testIds.length) { 31 | Logger.log('対象 testId はありませんでした') 32 | return 33 | } 34 | Logger.log('testIds: %s', testIds.join('\n')) 35 | const wpt = new WebPagetest() 36 | const results = testIds.map(testId => { 37 | const results = wpt.results(testId) 38 | if (results instanceof Error) { 39 | Logger.log('Failed to fetch test result', results) 40 | if (enabledNetworkErrorReport) { 41 | throw results 42 | } 43 | // Just return empty results if ignore network error 44 | return wpt.createEmptyTestResults() 45 | } 46 | return results 47 | }) 48 | 49 | const targetRange = sheet.getRange(lastCompletedRow + 1, 2, results.length, results[0].length) 50 | targetRange.setValues(results) 51 | } 52 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # WebPagetest API Key 2 | ## See https://www.webpagetest.org/getkey.php 3 | WEBPAGETEST_API_KEY= 4 | # Test Target URL 5 | RUN_TEST_URL=https://example.com 6 | # Run Test interval 7 | ## Set run test interval by using Google Apps Script Time-Based Trigger 8 | ## Execute runTest function every RUN_TEST_INTERVAL 9 | ## Example: 10 | ## `2h`, `1h`, or `30m` 11 | ## Limitation: 12 | ## - Can not combine hour with minutes 13 | ## - `1h30m` => Error 14 | ## - Allow to set one of `1m`, `5m`, `15m`, `30m` as minutes 15 | RUN_TEST_INTERVAL=30m 16 | # Sheet name to record 17 | SHEET_NAME=Sheet1 18 | # If it is 0, suppress Network Error report from Google Apps Scripts 19 | REPORT_NETWORK_ERROR=1 20 | 21 | # WebPagetest Options 22 | # https://sites.google.com/a/webpagetest.org/docs/advanced-features/webpagetest-restful-apis 23 | ## Number of test runs (1-10 on the public instance) 24 | ## gas-webpagetest use median results 25 | WEBPAGETEST_OPTIONS_RUNS=3 26 | ## Location to test from 27 | WEBPAGETEST_OPTIONS_LOCATION=ec2-ap-northeast-1.3GFast 28 | ## Set to 1 to skip the Repeat View test 29 | WEBPAGETEST_OPTIONS_FVONLY=1 30 | ## Set to 1 to capture video (video is required for calculating Speed Index) 31 | WEBPAGETEST_OPTIONS_VIDEO=1 32 | ## Set to 1 to disable optimization checks (for faster testing) 33 | WEBPAGETEST_OPTIONS_NO_OPTIMIZATION=1 34 | ## Set to 1 to have Chrome emulate a mobile browser 35 | WEBPAGETEST_OPTIONS_MOBILE=1 36 | ## Device name from mobile_devices.ini to use for mobile emulation 37 | ## only when mobile=1 is specified to enable emulation and only for Chrome 38 | WEBPAGETEST_OPTIONS_MOBILE_DEVICE=Pixel 39 | ## Set to 1 to have a lighthouse test also performed (Chrome-only, wptagent agents only) 40 | WEBPAGETEST_OPTIONS_LIGHTHOUSE=1 41 | ## WebPagetest Scripting Option 42 | ## Set file path to scripting file 43 | ## https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/scripting 44 | # WEBPAGETEST_OPTIONS_SCRIPT_PATH=./script.txt 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gapps.config.json 2 | .clasp.json 3 | /dist/ 4 | /lib/ 5 | /script.txt 6 | 7 | ### https://raw.github.com/github/gitignore/d5ce0fc1d4a677eaa60d5b8e01ac9e42ec7cfcd1/Node.gitignore 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (http://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Typescript v1 declaration files 48 | typings/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | 68 | 69 | 70 | ### https://raw.github.com/github/gitignore/d5ce0fc1d4a677eaa60d5b8e01ac9e42ec7cfcd1/Global/macOS.gitignore 71 | 72 | # General 73 | *.DS_Store 74 | .AppleDouble 75 | .LSOverride 76 | 77 | # Icon must end with two \r 78 | Icon 79 | 80 | 81 | # Thumbnails 82 | ._* 83 | 84 | # Files that might appear in the root of a volume 85 | .DocumentRevisions-V100 86 | .fseventsd 87 | .Spotlight-V100 88 | .TemporaryItems 89 | .Trashes 90 | .VolumeIcon.icns 91 | .com.apple.timemachine.donotpresent 92 | 93 | # Directories potentially created on remote AFP share 94 | .AppleDB 95 | .AppleDesktop 96 | Network Trash Folder 97 | Temporary Items 98 | .apdisk 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gas-webpagetest", 3 | "private": true, 4 | "scripts": { 5 | "create-gas": "node scripts/create.js", 6 | "deploy": "run-s clean build push", 7 | "push": "clasp push", 8 | "clean": "rimraf dist", 9 | "build": "webpack", 10 | "test": "jest", 11 | "test:updateSnapshot": "jest --updateSnapshot", 12 | "postbuild": "cpx appsscript.json dist", 13 | "prettier": "prettier --write \"**/*.{js,ts}\"", 14 | "precommit": "lint-staged", 15 | "postcommit": "git reset" 16 | }, 17 | "main": "index.js", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@google/clasp": "~2.1", 21 | "@types/google-apps-script": "^0.0.54", 22 | "@types/jest": "^24.0.15", 23 | "@types/node": "^12.0.10", 24 | "core-js": "^3.1.4", 25 | "cpx": "^1.5.0", 26 | "cross-spawn": "^6.0.5", 27 | "dotenv": "^8.0.0", 28 | "dotenv-webpack": "^1.7.0", 29 | "gas-webpack-plugin": "^1.0.2", 30 | "husky": "^2.7.0", 31 | "jest": "^24.8.0", 32 | "lint-staged": "^8.2.1", 33 | "npm-run-all": "^4.1.5", 34 | "prettier": "^1.18.2", 35 | "rimraf": "^2.6.3", 36 | "ts-jest": "^24.0.2", 37 | "ts-loader": "^6.0.4", 38 | "typescript": "^3.5.2", 39 | "webpack": "^4.35.0", 40 | "webpack-cli": "^3.3.5" 41 | }, 42 | "jest": { 43 | "testEnvironment": "node", 44 | "transform": { 45 | "^.+\\.tsx?$": "ts-jest" 46 | }, 47 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 48 | "testPathIgnorePatterns": [ 49 | "/node_modules/", 50 | "/lib/" 51 | ], 52 | "moduleFileExtensions": [ 53 | "ts", 54 | "tsx", 55 | "js", 56 | "jsx", 57 | "json", 58 | "node" 59 | ] 60 | }, 61 | "prettier": { 62 | "singleQuote": true, 63 | "printWidth": 100, 64 | "tabWidth": 2, 65 | "semi": false, 66 | "trailingComma": "es5" 67 | }, 68 | "lint-staged": { 69 | "*.{js,ts}": [ 70 | "prettier --write", 71 | "git add" 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/runTest.ts: -------------------------------------------------------------------------------- 1 | import WebPagetest = require('./WebPagetest') 2 | import Utils = require('./Utils') 3 | 4 | export const runTest = (): void => { 5 | const key = process.env.WEBPAGETEST_API_KEY 6 | if (!key) { 7 | throw new Error('should define WEBPAGETEST_API_KEY in .env') 8 | } 9 | const url = process.env.RUN_TEST_URL 10 | if (!url) { 11 | throw new Error('should define RUN_TEST_URL in .env') 12 | } 13 | const sheetName = process.env.SHEET_NAME 14 | if (!sheetName) { 15 | throw new Error('should define SHEET_NAME in .env') 16 | } 17 | // Optional 18 | const runs = Utils.parseNumberValue(process.env.WEBPAGETEST_OPTIONS_RUNS) 19 | const location = process.env.WEBPAGETEST_OPTIONS_LOCATION 20 | const fvonly = Utils.parseNumberValue(process.env.WEBPAGETEST_OPTIONS_FVONLY) 21 | const video = Utils.parseNumberValue(process.env.WEBPAGETEST_OPTIONS_VIDEO) 22 | const noOptimization = Utils.parseNumberValue(process.env.WEBPAGETEST_OPTIONS_NO_OPTIMIZATION) 23 | const mobile = Utils.parseNumberValue(process.env.WEBPAGETEST_OPTIONS_MOBILE) 24 | const mobileDevice = process.env.WEBPAGETEST_OPTIONS_MOBILE_DEVICE 25 | const lighthouse = Utils.parseNumberValue(process.env.WEBPAGETEST_OPTIONS_LIGHTHOUSE) 26 | const script = process.env.WEBPAGETEST_OPTIONS_SCRIPT_CODE 27 | const enabledNetworkErrorReport = Utils.parseBooleanNumberValue(process.env.NETWORK_ERROR_REPORT) 28 | const wpt = new WebPagetest(key) 29 | let testId 30 | try { 31 | testId = wpt.test(url, { 32 | runs, 33 | location, 34 | fvonly, 35 | video, 36 | // format: JSON for getTestResults 37 | format: 'JSON', 38 | noOptimization, 39 | mobile, 40 | mobileDevice, 41 | lighthouse, 42 | script, 43 | }) 44 | } catch (error) { 45 | Logger.log('Failed runTest', error) 46 | if (enabledNetworkErrorReport) { 47 | throw new error 48 | } 49 | return 50 | } 51 | const activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet() 52 | if (!activeSpreadsheet) { 53 | throw new Error('Not found active spreadsheet') 54 | } 55 | const sheet = activeSpreadsheet.getSheetByName(sheetName) 56 | if (!sheet) { 57 | throw new Error(`Not found sheet by name:${sheetName}`) 58 | } 59 | const lastRow = sheet.getLastRow() 60 | const targetCell = sheet.getRange(lastRow + 1, 1) 61 | 62 | targetCell.setValue(testId) 63 | } 64 | -------------------------------------------------------------------------------- /src/__tests__/Utils.test.ts: -------------------------------------------------------------------------------- 1 | import Utils = require('../Utils') 2 | 3 | describe('Utils', () => { 4 | describe('parseNumberValue', () => { 5 | it('should return undefined if value is undefined', () => { 6 | expect(Utils.parseNumberValue()).toBeUndefined() 7 | }) 8 | it('should return undefined if value is empty string', () => { 9 | expect(Utils.parseNumberValue('')).toBeUndefined() 10 | }) 11 | it('should return number if value is number string', () => { 12 | expect(Utils.parseNumberValue('0')).toBe(0) 13 | expect(Utils.parseNumberValue('1')).toBe(1) 14 | expect(Utils.parseNumberValue('99.9')).toBe(99.9) 15 | }) 16 | it('should throw error if value can not be parsed', () => { 17 | expect(() => { 18 | Utils.parseNumberValue('1+0') 19 | }).toThrow() 20 | }) 21 | }) 22 | describe('parseBooleanNumberValue', () => { 23 | it('should return false if value is undefined', () => { 24 | expect(Utils.parseBooleanNumberValue()).toBe(false) 25 | }) 26 | it('should return false if value is "0"', () => { 27 | expect(Utils.parseBooleanNumberValue('0')).toBe(false) 28 | }) 29 | it('should return true if value is "1"', () => { 30 | expect(Utils.parseBooleanNumberValue('1')).toBe(true) 31 | }) 32 | 33 | it('should throw error if value can not be acceptable value', () => { 34 | expect(() => { 35 | Utils.parseBooleanNumberValue('-1') 36 | }).toThrow() 37 | expect(() => { 38 | Utils.parseBooleanNumberValue('2') 39 | }).toThrow() 40 | expect(() => { 41 | Utils.parseBooleanNumberValue('10') 42 | }).toThrow() 43 | expect(() => { 44 | Utils.parseBooleanNumberValue('a') 45 | }).toThrow() 46 | }) 47 | }) 48 | describe('parseTimeFormat', () => { 49 | it('should return { type: HOUR } when pass 1h', () => { 50 | expect(Utils.parseTimeFormat('1h')).toEqual({ 51 | type: 'HOUR', 52 | value: 1, 53 | }) 54 | }) 55 | it('should return { type: HOUR } when pass 24h', () => { 56 | expect(Utils.parseTimeFormat('24h')).toEqual({ 57 | type: 'HOUR', 58 | value: 24, 59 | }) 60 | }) 61 | it('should return { type: MINUTE } when pass 30m', () => { 62 | expect(Utils.parseTimeFormat('30m')).toEqual({ 63 | type: 'MINUTE', 64 | value: 30, 65 | }) 66 | }) 67 | it('should return error when pass 1h30m', () => { 68 | expect(Utils.parseTimeFormat('1h30m')).toBeInstanceOf(Error) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | class Utils { 2 | public static fetch(url: string) { 3 | const response = UrlFetchApp.fetch(url) 4 | return JSON.parse(response.getContentText()) 5 | } 6 | 7 | public static getLastRow(sheet, column) { 8 | const targetColumnRange = sheet.getRange(`${column}:${column}`) 9 | const lowermostCell = sheet.getRange( 10 | targetColumnRange.getLastRow(), 11 | targetColumnRange.getColumn() 12 | ) 13 | if (lowermostCell.getValue()) { 14 | return lowermostCell.getRow() 15 | } else { 16 | return lowermostCell.getNextDataCell(SpreadsheetApp.Direction.UP).getRow() 17 | } 18 | } 19 | 20 | public static transform(value, digits = 2) { 21 | return (value / 1000).toFixed(digits) 22 | } 23 | 24 | /** 25 | * valueを数値としてパースしnumberを返す 26 | * 空文字や引数が空の場合はundefinedを返す 27 | * パースできない値は例外を投げる 28 | * @param value 29 | */ 30 | public static parseNumberValue(value?: string): number | undefined { 31 | if (!value) { 32 | return undefined 33 | } 34 | const numberValue = Number(value) 35 | if (isNaN(numberValue)) { 36 | throw new Error(`This value can not be parsed as number: ${value}`) 37 | } 38 | return numberValue 39 | } 40 | 41 | /** 42 | * Parse value as boolean number 43 | * true: 1 44 | * false: 0 45 | */ 46 | public static parseBooleanNumberValue(value?: string): boolean { 47 | if (!value) { 48 | return false 49 | } 50 | if (value !== '0' && value !== '1') { 51 | throw new Error(`This value should be "0" or "1": ${value}`) 52 | } 53 | const numberValue = Number(value) 54 | if (isNaN(numberValue)) { 55 | throw new Error(`This value can not be parsed as number: ${value}`) 56 | } 57 | return numberValue === 1 58 | } 59 | 60 | /** 61 | * Parse time formatted value and return { type, value } 62 | * Accept format: 63 | * - : number 64 | * - : `h` or `m` 65 | */ 66 | public static parseTimeFormat( 67 | value?: string 68 | ): { type: 'HOUR' | 'MINUTE'; value: number } | Error { 69 | if (!value) { 70 | return new Error('value is undefined') 71 | } 72 | const TIME_FORMATS: { type: 'HOUR' | 'MINUTE'; pattern: RegExp }[] = [ 73 | { 74 | type: 'HOUR', 75 | pattern: /^(\d+)h$/, 76 | }, 77 | { 78 | type: 'MINUTE', 79 | pattern: /^(\d+)m$/, 80 | }, 81 | ] 82 | for (let i = 0; i < TIME_FORMATS.length; i++) { 83 | const format = TIME_FORMATS[i] 84 | if (format.pattern.test(value)) { 85 | const match = value.match(format.pattern) 86 | if (!match) { 87 | throw new Error(`Does not parsed value: ${value}`) 88 | } 89 | return { 90 | type: format.type, 91 | value: Number(match[1]), 92 | } 93 | } 94 | } 95 | return new Error(`Does not parsed value: ${value}`) 96 | } 97 | } 98 | 99 | export = Utils 100 | -------------------------------------------------------------------------------- /src/__tests__/WebPagetest.test.ts: -------------------------------------------------------------------------------- 1 | import WebPagetest = require('../WebPagetest') 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | 5 | describe('WebPagetest', () => { 6 | describe('#generateRunTestURL', () => { 7 | it('should return test url when it has not options', () => { 8 | const webPagetest = new WebPagetest('key') 9 | const url = webPagetest.generateRunTestURL('https://example.com') 10 | expect(url).toMatchInlineSnapshot( 11 | `"https://www.webpagetest.org/runtest.php?url=https%3A%2F%2Fexample.com&location=ec2-ap-northeast-1.3GFast&runs=1&fvonly=1&video=1&f=JSON&noopt=1&k=key&mobile=1&mobileDevice=Pixel&lighthouse=1"` 12 | ) 13 | }) 14 | it('should return test url when it has options', () => { 15 | const webPagetest = new WebPagetest('key') 16 | const url = webPagetest.generateRunTestURL('https://example.com', { 17 | location: 'ec2-ap-northeast-1:Chrome', 18 | runs: 1, 19 | fvonly: 0, 20 | video: 0, 21 | mobile: 0, 22 | format: 'JSON', 23 | noOptimization: 0, 24 | mobileDevice: 'iPhone5c', 25 | lighthouse: 0, 26 | }) 27 | expect(url).toMatchInlineSnapshot( 28 | `"https://www.webpagetest.org/runtest.php?url=https%3A%2F%2Fexample.com&location=ec2-ap-northeast-1%3AChrome&runs=1&fvonly=0&video=0&f=JSON&noopt=0&k=key&mobile=0&mobileDevice=iPhone5c&lighthouse=0"` 29 | ) 30 | }) 31 | it('should return test url that include script when it has script', () => { 32 | const webPagetest = new WebPagetest('key') 33 | const url = webPagetest.generateRunTestURL('https://example.com', { 34 | script: `logData 0 35 | 36 | // put any urls you want to navigate 37 | navigate www.aol.com 38 | navigate news.aol.com 39 | 40 | logData 1 41 | 42 | // this step will get recorded 43 | navigate news.aol.com/world 44 | `, 45 | }) 46 | expect(url).toMatchInlineSnapshot( 47 | `"https://www.webpagetest.org/runtest.php?url=https%3A%2F%2Fexample.com&location=ec2-ap-northeast-1.3GFast&runs=1&fvonly=1&video=1&f=JSON&noopt=1&k=key&mobile=1&mobileDevice=Pixel&lighthouse=1&script=logData%20%20%20%200%0A%0A%2F%2F%20put%20any%20urls%20you%20want%20to%20navigate%0Anavigate%20%20%20%20www.aol.com%0Anavigate%20%20%20%20news.aol.com%0A%0AlogData%20%20%20%201%0A%0A%2F%2F%20this%20step%20will%20get%20recorded%0Anavigate%20%20%20%20news.aol.com%2Fworld%0A"` 48 | ) 49 | }) 50 | }) 51 | describe('generateTestResult', () => { 52 | beforeAll(() => { 53 | // stub https://developers.google.com/apps-script/reference/utilities/utilities#formatdatedate-timezone-format 54 | ;(global as any).Utilities = { 55 | formatDate: (date: Date, timeZone: string, format: string) => { 56 | return `${date.toISOString()}, ${timeZone}, ${format}` 57 | }, 58 | } 59 | }) 60 | afterAll(() => { 61 | delete (global as any).Utilities 62 | }) 63 | it('should return results', () => { 64 | const webPagetest = new WebPagetest('key') 65 | const snapshotTargets = [ 66 | path.join(__dirname, 'fixtures/WebPagetest-response-google.com.json'), 67 | path.join(__dirname, 'fixtures/WebPagetest-response-youtube.com.json'), 68 | ] 69 | snapshotTargets.forEach(filePath => { 70 | const response = JSON.parse(fs.readFileSync(filePath, 'utf-8')) 71 | const names = webPagetest.generateTestResultNames() 72 | const result = webPagetest.convertWebPageResponseToResult(response) 73 | const values = webPagetest.generateTestResultValues(result) 74 | const actualResults = names.map((name, index) => { 75 | return { 76 | name, 77 | value: values[index], 78 | } 79 | }) 80 | expect(actualResults).toMatchSnapshot(path.basename(filePath, '.json')) 81 | }) 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gas-webpagetest 2 | 3 | > ### *You can't control what you can't measure.* 4 | > Tom DeMarco (1982) Controlling Software Projects. 5 | 6 | This [Google Apps Scripts](https://developers.google.com/apps-script/) help to measure your website using [WebPagetest](https://www.webpagetest.org/). 7 | 8 | ## Requirements 9 | 10 | - Node.js 11 | - This script is written by Node.js 12 | - [Yarn](https://yarnpkg.com/) 13 | - package manager 14 | - This repository is managed by Yarn 15 | - Google Account 16 | - Need to login with [clasp](https://github.com/google/clasp). 17 | - `gas-webpagetest` is a Google Apps Script. 18 | - Google Spreadsheet 19 | - `gas-webpagetest` record the result of WebPagetest to Google Spreadsheet 20 | - 1 sheet = 1 site 21 | - WebPagetest API Key 22 | - [WebPagetest - Get API Key](https://www.webpagetest.org/getkey.php) 23 | - `gas-webpagetest` call WebPagetest API and record it. 24 | 25 | ### Optional 26 | 27 | - [Google DataStudio](https://datastudio.google.com/) 28 | - It help to visualize your data 29 | 30 | ## Usage 31 | 32 | ### Installation 33 | 34 | 1. git clone this repository 35 | 36 | ``` 37 | git clone https://github.com/uknmr/gas-webpagetest.git 38 | cd gas-webpagetest 39 | ``` 40 | 41 | 2. Install dependencies by yarn 42 | 43 | 44 | ``` 45 | yarn install 46 | ``` 47 | 48 | 49 | 3. If you never use `clasp`, please do `clasp login` 50 | 51 | ``` 52 | yarn clasp login 53 | # Login and Authorize clasp 54 | ``` 55 | 56 | ### Integrate Google Spreadsheet with Google Apps Script(`gas-webpagetest`) 57 | 58 | 4. Create empty spreadsheet that is recorded result of WebPagetest. 59 | - You should copy spreadsheet id 60 | - For example, your spreadsheet url is `https://docs.google.com/spreadsheets/d/asn__asxScJZi-2asd4242sd23HO441Ok/edit#gid=0` 61 | - `asn__asxScJZi-2asd4242sdHOeB6t5XFdOk` is a **spreadsheet id** and copy it 62 | 63 | 5. Create new Google Apps Script and connect it your spreadsheet. 64 | 65 | Run following command: 66 | 67 | ``` 68 | yarn run create-gas "