├── example ├── outDir │ ├── css │ │ └── .gitkeep │ └── img │ │ └── .gitkeep └── cases │ └── demo.yaml ├── cli ├── src │ ├── randexp.d.ts │ ├── main.ts │ ├── util.ts │ ├── __tests__ │ │ ├── util.ts │ │ └── run.ts │ ├── types.ts │ ├── bin │ │ └── cli.ts │ ├── handlers │ │ ├── chrome-handlers.ts │ │ └── __tests__ │ │ │ └── chrome-handlers.ts │ └── run.ts ├── jest.config.js ├── tsconfig.json ├── tslint.json ├── samples │ ├── sample.yaml │ └── convert_test.yaml ├── .gitignore ├── .npmignore ├── README.md ├── package.json └── LICENSE ├── README.md └── .gitignore /example/outDir/css/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/outDir/img/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cli/src/randexp.d.ts: -------------------------------------------------------------------------------- 1 | declare let randexp: any; 2 | export default randexp; 3 | -------------------------------------------------------------------------------- /example/cases/demo.yaml: -------------------------------------------------------------------------------- 1 | name: demo 2 | url: 'http://example.com/' 3 | 4 | steps: 5 | - action: 6 | type: wait 7 | duration: 500 8 | - action: 9 | type: screenshot 10 | name: 'demo' 11 | -------------------------------------------------------------------------------- /cli/src/main.ts: -------------------------------------------------------------------------------- 1 | import { default as d } from "debug"; 2 | const debug = d("pprunner"); 3 | 4 | // exports handlers 5 | import * as ChromeHandler from "./handlers/chrome-handlers"; 6 | 7 | export { ChromeHandler }; 8 | export * from "./types"; 9 | export * from "./run"; 10 | -------------------------------------------------------------------------------- /cli/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "ts-jest" 4 | }, 5 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 6 | moduleFileExtensions: [ 7 | "ts", 8 | "js", 9 | "json", 10 | "node" 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "downlevelIteration": true, 6 | "esModuleInterop": true, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "inlineSourceMap": true, 10 | "declaration": true, 11 | "outDir": "./lib", 12 | "lib": ["es6", "es2015.promise", "dom", "es2017"] 13 | }, 14 | "include": ["./src/**/*"], 15 | "exclude": ["./node_modules", "./**/__tests__/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /cli/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-config-prettier", 5 | "tslint-react", 6 | "tslint-plugin-prettier" 7 | ], 8 | "rules": { 9 | "prettier": true, 10 | "no-console": false, 11 | "interface-over-type-literal": false, 12 | "no-submodule-imports": false, 13 | "jsx-no-multiline-js": false, 14 | "jsx-curly-spacing": false, 15 | "jsx-no-lambda": false, 16 | "object-literal-sort-keys": false, 17 | "no-implicit-dependencies": [true, "dev"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cli/samples/sample.yaml: -------------------------------------------------------------------------------- 1 | skip: false 2 | name: 'sample' 3 | version: 1 4 | url: 'http://localhost:3000/login' 5 | iteration: 1 6 | steps: 7 | - action: 8 | type: input 9 | form: 10 | selector: 'input[name="email"]' 11 | value: 'test@example.com' 12 | - action: 13 | type: input 14 | form: 15 | selector: 'input[name="password"]' 16 | value: 'passw0rd' 17 | - action: 18 | type: click 19 | selector: 'button[type="submit"]' 20 | - action: 21 | type: wait 22 | duration: 1000 23 | - action: 24 | type: screenshot 25 | name: 'login_success' 26 | -------------------------------------------------------------------------------- /cli/samples/convert_test.yaml: -------------------------------------------------------------------------------- 1 | skip: false 2 | name: 'convert_test' 3 | version: 1 4 | url: '{{ hostUrl }}/login' 5 | iteration: 1 6 | steps: 7 | - action: 8 | type: input 9 | form: 10 | selector: 'input[name="email"]' 11 | value: '{{ userId }}' 12 | - action: 13 | type: input 14 | form: 15 | selector: 'input[name="password"]' 16 | value: '{{ password }}' 17 | - action: 18 | type: click 19 | selector: 'button[type="submit"]' 20 | - action: 21 | type: wait 22 | duration: 1000 23 | - action: 24 | type: screenshot 25 | name: 'login_success' 26 | -------------------------------------------------------------------------------- /cli/src/util.ts: -------------------------------------------------------------------------------- 1 | import { default as Handlebars } from "handlebars"; 2 | import { BrowserType } from "./types"; 3 | 4 | export function convert(yaml: string): string { 5 | const data = { 6 | hostUrl: process.env.HOST_URL || "http://localhost:3000", 7 | password: process.env.PASSWORD || "passw0rd", 8 | userId: process.env.USER_ID || "test@example.com" 9 | }; 10 | registerEnvHelper(Handlebars); 11 | const template = Handlebars.compile(yaml); 12 | const converted = template(data); 13 | return converted; 14 | } 15 | 16 | export function isPuppeteer(browser: any): boolean { 17 | return browser.newPage !== undefined; 18 | } 19 | 20 | function registerEnvHelper(handlebars) { 21 | handlebars.registerHelper("env", (envName, options) => { 22 | if (process.env[envName] && process.env[envName] !== "") { 23 | return process.env[envName]; 24 | } 25 | 26 | return options.hash.default; 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /cli/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (http://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # Typescript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # End of https://www.gitignore.io/api/node 65 | 66 | lib 67 | images 68 | doc 69 | images 70 | -------------------------------------------------------------------------------- /cli/.npmignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | .idea 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # parcel-bundler cache (https://parceljs.org/) 67 | .cache 68 | 69 | # next.js build output 70 | .next 71 | 72 | # nuxt.js build output 73 | .nuxt 74 | 75 | # vuepress build output 76 | .vuepress/dist 77 | 78 | # Serverless directories 79 | .serverless 80 | 81 | 82 | # End of https://www.gitignore.io/api/node 83 | images -------------------------------------------------------------------------------- /cli/src/__tests__/util.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { default as fs } from "fs"; 3 | import { default as path } from "path"; 4 | import { convert } from "../util"; 5 | 6 | const expectedConvertYaml = ({ hostUrl, userId, password }) => `skip: false 7 | name: 'convert_test' 8 | version: 1 9 | url: '${hostUrl}/login' 10 | iteration: 1 11 | steps: 12 | - action: 13 | type: input 14 | form: 15 | selector: 'input[name="email"]' 16 | value: '${userId}' 17 | - action: 18 | type: input 19 | form: 20 | selector: 'input[name="password"]' 21 | value: '${password}' 22 | - action: 23 | type: click 24 | selector: 'button[type="submit"]' 25 | - action: 26 | type: wait 27 | duration: 1000 28 | - action: 29 | type: screenshot 30 | name: 'login_success' 31 | `; 32 | 33 | describe("utils convert functions test", () => { 34 | test("convert simple test", () => { 35 | const hostUrl = process.env.HOST_URL || "http://localhost:3000"; 36 | const original = `{{ hostUrl }}`; 37 | const yaml = convert(original); 38 | assert.strictEqual(yaml, hostUrl); 39 | }); 40 | test("convert file test", async () => { 41 | const hostUrl = process.env.HOST_URL || "http://localhost:3000"; 42 | const userId = process.env.USER_ID || "test@example.com"; 43 | const password = process.env.PASSWORD || "passw0rd"; 44 | const f = path.resolve("samples/convert_test.yaml"); 45 | const originalBuffer = fs.readFileSync(f); 46 | const originalYaml = originalBuffer.toString(); 47 | const yaml = convert(originalYaml); 48 | assert.strictEqual( 49 | yaml, 50 | expectedConvertYaml({ hostUrl, userId, password }) 51 | ); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # css-optimization 2 | 3 | This tool uses puppeteer's coverage feature to output an optimized CSS file. 4 | 5 | As a feature of the tool, by using [pupperium](https://github.com/akito0107/pupperium), user can operate [puppeteer](https://github.com/GoogleChrome/puppeteer) with the yaml file. 6 | 7 | Media query and font-face, etc. are not deleted because PostCSS AST node is used. 8 | 9 | ### Installing 10 | ``` 11 | $ npm install -g css-optimization 12 | ``` 13 | 14 | ### How to use 15 | ``` 16 | $ css-optimization -p -i -c 17 | ``` 18 | 19 | ### Options 20 | ``` 21 | $ css-optimization --help 22 | Usage: css-optimization [options] 23 | 24 | Options: 25 | -V, --version output the version number 26 | -p, --path cases root dir 27 | -i, --image-dir screehshots dir 28 | -c, --css-dir optimize css dir 29 | -h, --disable-headless disable headless mode 30 | -h, --help output usage information 31 | ``` 32 | 33 | ### example: case file 34 | ``` 35 | name: demo 36 | url: 'http://example.com/' 37 | userAgent: 'bot' 38 | 39 | steps: 40 | - action: 41 | type: hover 42 | selector: '.fuga' 43 | - action: 44 | type: click 45 | selector: '.hoge' 46 | - action: 47 | type: wait 48 | duration: 500 49 | - action: 50 | type: select 51 | selector: '.fuga' 52 | - action: 53 | type: focus 54 | selector: '.fuga' 55 | - action: 56 | type: screenshot 57 | name: 'demo' 58 | ``` 59 | 60 | ### Demo 61 | ``` 62 | $ git clone https://github.com/toshi1127/css-optimization.git 63 | $ cd cli 64 | $ npm install 65 | $ npm run build 66 | $ npm run start:demo 67 | ``` 68 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # css-optimization 2 | 3 | This tool uses puppeteer's coverage feature to output an optimized CSS file. 4 | 5 | As a feature of the tool, by using [pupperium](https://github.com/akito0107/pupperium), user can operate [puppeteer](https://github.com/GoogleChrome/puppeteer) with the yaml file. 6 | 7 | Media query and font-face, etc. are not deleted because PostCSS AST node is used. 8 | 9 | ### Installing 10 | ``` 11 | $ npm install -g css-optimization 12 | ``` 13 | 14 | ### How to use 15 | ``` 16 | $ css-optimization -p -i -c 17 | ``` 18 | 19 | ### Options 20 | ``` 21 | $ css-optimization --help 22 | Usage: css-optimization [options] 23 | 24 | Options: 25 | -V, --version output the version number 26 | -p, --path cases root dir 27 | -i, --image-dir screehshots dir 28 | -c, --css-dir optimize css dir 29 | -h, --disable-headless disable headless mode 30 | -h, --help output usage information 31 | ``` 32 | 33 | ### example: case file 34 | ``` 35 | name: demo 36 | url: 'http://example.com/' 37 | userAgent: 'bot' 38 | 39 | steps: 40 | - action: 41 | type: hover 42 | selector: '.fuga' 43 | - action: 44 | type: click 45 | selector: '.hoge' 46 | - action: 47 | type: wait 48 | duration: 500 49 | - action: 50 | type: select 51 | selector: '.fuga' 52 | - action: 53 | type: focus 54 | selector: '.fuga' 55 | - action: 56 | type: screenshot 57 | name: 'demo' 58 | ``` 59 | 60 | ### Demo 61 | ``` 62 | $ git clone https://github.com/toshi1127/css-optimization.git 63 | $ cd cli 64 | $ npm install 65 | $ npm run build 66 | $ npm run start:demo 67 | ``` 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | # Edit at https://www.gitignore.io/?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # TypeScript v1 declaration files 49 | typings/ 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional REPL history 58 | .node_repl_history 59 | 60 | # Output of 'npm pack' 61 | *.tgz 62 | 63 | # Yarn Integrity file 64 | .yarn-integrity 65 | 66 | # dotenv environment variables file 67 | .env 68 | .env.test 69 | 70 | # parcel-bundler cache (https://parceljs.org/) 71 | .cache 72 | 73 | # next.js build output 74 | .next 75 | 76 | # nuxt.js build output 77 | .nuxt 78 | 79 | # vuepress build output 80 | .vuepress/dist 81 | 82 | # Serverless directories 83 | .serverless/ 84 | 85 | # FuseBox cache 86 | .fusebox/ 87 | 88 | # DynamoDB Local files 89 | .dynamodb/ 90 | 91 | example/outDir/css/* 92 | example/outDir/img/* 93 | !.gitkeep 94 | # End of https://www.gitignore.io/api/node 95 | -------------------------------------------------------------------------------- /cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-optimization", 3 | "version": "1.7.1", 4 | "main": "lib/main.js", 5 | "types": "lib/main.d.ts", 6 | "repository": "toshi1127/css-optimization", 7 | "license": "apache-2.0", 8 | "author": { 9 | "name": "toshi1127", 10 | "email": "toshi.matsumoto.3n@gmail.com" 11 | }, 12 | "preferGlobal": true, 13 | "bin": { 14 | "css-optimization": "lib/bin/cli.js" 15 | }, 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "scripts": { 20 | "lint": "tslint ./src/**/*.ts ./src/*.ts", 21 | "lint:fix": "tslint --fix ./src/**/*.ts ./src/*.ts", 22 | "clean": "rimraf ./lib/*", 23 | "build": "npm run clean && tsc --outDir lib", 24 | "watch": "npm run clean && tsc --outDir lib --watch", 25 | "test": "jest", 26 | "start:demo": "node ./lib/bin/cli.js -p ../example/cases -i ../example/outDir/img -c ../example/outDir/css" 27 | }, 28 | "devDependencies": { 29 | "@types/debug": "^4.1.5", 30 | "@types/faker": "^4.1.5", 31 | "@types/jest": "^24.0.18", 32 | "@types/node": "^12.7.5", 33 | "@types/puppeteer": "^1.19.1", 34 | "express": "^4.17.1", 35 | "jest": "^24.9.0", 36 | "npm-run-all": "^4.1.5", 37 | "power-assert": "^1.6.1", 38 | "prettier": "^1.18.2", 39 | "rimraf": "^3.0.0", 40 | "ts-jest": "^24.1.0", 41 | "ts-node": "^8.4.1", 42 | "tslint": "^5.20.0", 43 | "tslint-config-prettier": "^1.18.0", 44 | "tslint-plugin-prettier": "^2.0.1", 45 | "tslint-react": "^4.0.0", 46 | "typescript": "^3.6.3", 47 | "wait-port": "^0.2.2" 48 | }, 49 | "dependencies": { 50 | "commander": "^3.0.1", 51 | "debug": "^4.1.1", 52 | "faker": "^4.1.0", 53 | "handlebars": "^4.3.0", 54 | "immer": "^4.0.0", 55 | "js-yaml": "^3.13.1", 56 | "p-iteration": "^1.1.8", 57 | "postcss": "^7.0.18", 58 | "pretty": "^2.0.0", 59 | "puppeteer": "^1.20.0", 60 | "randexp": "^0.5.3", 61 | "recursive-readdir": "^2.2.2", 62 | "source-map-support": "^0.5.13" 63 | }, 64 | "keywords": [ 65 | "puppeteer", 66 | "pupperium", 67 | "css", 68 | "cli" 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /cli/src/__tests__/run.ts: -------------------------------------------------------------------------------- 1 | import { default as produce } from "immer"; 2 | import * as assert from "power-assert"; 3 | import { handleAction, handlePreCondition, handleIteration } from "../run"; 4 | 5 | const handlers = { 6 | action1: async (page, action, { context }) => { 7 | assert.deepStrictEqual(context, { 8 | currentIteration: 0, 9 | iterations: [{ steps: [] }] 10 | }); 11 | return { value: "action1" }; 12 | }, 13 | action2: async (page, action, { context }) => { 14 | assert.deepStrictEqual(context, { 15 | currentIteration: 0, 16 | iterations: [{ steps: [{ value: "action1" }] }] 17 | }); 18 | return { value: "action2" }; 19 | }, 20 | action3: async (page, action, { context }) => { 21 | assert.deepStrictEqual(context, { 22 | currentIteration: 0, 23 | iterations: [{ steps: [{ value: "action1" }, { value: "action2" }] }] 24 | }); 25 | return { value: "action3" }; 26 | }, 27 | goto: async () => ({}) 28 | }; 29 | 30 | describe("handleAction", () => { 31 | test("reduce context", async () => { 32 | const result = await handleAction( 33 | 0, 34 | {} as any, 35 | handlers as any, 36 | [ 37 | { action: { type: "action1" } }, 38 | { action: { type: "action2" } }, 39 | { action: { type: "action3" } } 40 | ] as any, 41 | { 42 | context: { 43 | currentIteration: 0, 44 | iterations: [{ steps: [] }] 45 | } 46 | } as any, 47 | (ctx, res) => { 48 | return produce(ctx, draft => { 49 | draft.iterations[0].steps.push(res); 50 | }); 51 | } 52 | ); 53 | 54 | assert.deepStrictEqual(result, { 55 | currentIteration: 0, 56 | iterations: [ 57 | { 58 | steps: [ 59 | { value: "action1" }, 60 | { value: "action2" }, 61 | { value: "action3" } 62 | ] 63 | } 64 | ] 65 | }); 66 | }); 67 | }); 68 | 69 | describe("handlePreCondition", () => { 70 | test("reduce precondition context", async () => { 71 | const result = await handlePreCondition( 72 | {} as any, 73 | { 74 | action1: async (page, action, { context }) => { 75 | return { value: "action1" }; 76 | }, 77 | action2: async (page, action, { context }) => { 78 | return { value: "action2" }; 79 | }, 80 | action3: async (page, action, { context }) => { 81 | return { value: "action3" }; 82 | }, 83 | goto: async () => ({}) 84 | } as any, 85 | { 86 | steps: [ 87 | { action: { type: "action1" } }, 88 | { action: { type: "action2" } }, 89 | { action: { type: "action3" } } 90 | ] 91 | } as any, 92 | { context: { precondition: { steps: [] } } } as any 93 | ); 94 | 95 | assert.deepStrictEqual(result, { 96 | precondition: { 97 | steps: [ 98 | { value: "action1" }, 99 | { value: "action2" }, 100 | { value: "action3" } 101 | ] 102 | } 103 | }); 104 | }); 105 | }); 106 | 107 | describe("handleIteration", () => { 108 | test("reduce iterate context", async () => { 109 | const result = await handleIteration( 110 | {} as any, 111 | { 112 | action1: async (page, action, { context }) => { 113 | return { value: "action1" }; 114 | }, 115 | action2: async (page, action, { context }) => { 116 | return { value: "action2" }; 117 | }, 118 | goto: async () => ({}) 119 | } as any, 120 | { 121 | iteration: 2, 122 | steps: [ 123 | { action: { type: "action1" } }, 124 | { action: { type: "action2" } } 125 | ] 126 | } as any, 127 | { context: { iterations: [{ steps: [] }] } } as any 128 | ); 129 | 130 | assert.deepStrictEqual(result, { 131 | iterations: [ 132 | { 133 | steps: [{ value: "action1" }, { value: "action2" }] 134 | }, 135 | { 136 | steps: [{ value: "action1" }, { value: "action2" }] 137 | } 138 | ] 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /cli/src/types.ts: -------------------------------------------------------------------------------- 1 | // ここにActionを追加する 2 | 3 | import { PathLike } from "fs"; 4 | import { default as puppeteer } from "puppeteer"; 5 | import { ActionName, ActionType, RunnerOptions } from "./main"; 6 | 7 | export type Context = { 8 | info: { 9 | name: string; 10 | options: RunnerOptions; 11 | }; 12 | currentIteration: number; 13 | precondition: { 14 | steps: Array<{}>; 15 | }; 16 | iterations: Array<{ 17 | steps: Array<{}>; 18 | }>; 19 | postcondition: { 20 | steps: Array<{}>; 21 | }; 22 | error?: Error; 23 | }; 24 | 25 | export type BrowserType = "chrome" | "firefox"; 26 | 27 | export type BrowserEngine = puppeteer.Browser; 28 | 29 | export type BrowserPage = puppeteer.Page; 30 | 31 | export type ActionHandler = ( 32 | page: BrowserPage, 33 | action: ActionType, 34 | options?: { imageDir: PathLike; browserType: E; context: Context } 35 | ) => Promise; 36 | 37 | export type Scenario = { 38 | skip?: boolean; 39 | name: string; 40 | iteration: number; 41 | url: string; 42 | coverage?: boolean; 43 | precondition?: PreCondition; 44 | steps: Action[]; 45 | postcondition?: PostCondition; 46 | userAgent?: string; 47 | }; 48 | 49 | export type PreCondition = { 50 | url: string; 51 | steps: Action[]; 52 | }; 53 | 54 | export type PostCondition = { 55 | steps: Action[]; 56 | }; 57 | 58 | export type Action = 59 | | InputAction 60 | | ClickAction 61 | | SelectAction 62 | | WaitAction 63 | | EnsureAction 64 | | RadioAction 65 | | ScreenshotAction 66 | | GotoAction 67 | | ClearAction 68 | | DumpAction; 69 | 70 | type Value = 71 | | string 72 | | { 73 | faker: string; 74 | } 75 | | { 76 | date: string; 77 | }; 78 | 79 | export type ActionName = 80 | | "input" 81 | | "click" 82 | | "select" 83 | | "hover" 84 | | "wait" 85 | | "ensure" 86 | | "radio" 87 | | "screenshot" 88 | | "goto" 89 | | "clear" 90 | | "dump"; 91 | // | "coverage.stopCSSCoverage" 92 | // | "coverage.startCSSCoverage"; 93 | 94 | export type ActionType = T extends "input" 95 | ? InputAction 96 | : T extends "click" 97 | ? ClickAction 98 | : T extends "select" 99 | ? SelectAction 100 | : T extends "wait" 101 | ? WaitAction 102 | : T extends "ensure" 103 | ? EnsureAction 104 | : T extends "radio" 105 | ? RadioAction 106 | : T extends "screenshot" 107 | ? ScreenshotAction 108 | : T extends "goto" 109 | ? GotoAction 110 | : T extends "clear" 111 | ? ClearAction 112 | : T extends "dump" 113 | ? DumpAction 114 | : T extends "focus" 115 | ? FocusAction 116 | : T extends "hover" 117 | ? HoverAction 118 | : never; 119 | 120 | type Constrains = { 121 | required: boolean; 122 | regexp: string; 123 | }; 124 | 125 | type ActionMeta = { 126 | name?: string; 127 | tag?: string; 128 | }; 129 | 130 | export type InputAction = { 131 | action: { 132 | meta?: ActionMeta; 133 | type: "input"; 134 | form: { 135 | selector: string; 136 | constrains?: Constrains; 137 | value?: Value; 138 | }; 139 | }; 140 | }; 141 | 142 | export type ClickAction = { 143 | action: { 144 | meta?: ActionMeta; 145 | type: "click"; 146 | selector: string; 147 | navigation: boolean; 148 | avoidClear: boolean; 149 | emulateMouse: boolean; 150 | }; 151 | }; 152 | 153 | export type SelectAction = { 154 | action: { 155 | meta?: ActionMeta; 156 | type: "select"; 157 | form: { 158 | selector: string; 159 | constrains: { 160 | required: boolean; 161 | values: Value[]; 162 | }; 163 | }; 164 | }; 165 | }; 166 | 167 | export type WaitAction = { 168 | action: { 169 | meta?: ActionMeta; 170 | name?: string; 171 | type: "wait"; 172 | duration: number; 173 | }; 174 | }; 175 | 176 | export type ScreenshotAction = { 177 | action: { 178 | meta?: ActionMeta; 179 | type: "screenshot"; 180 | name: string; 181 | fullPage?: boolean; 182 | }; 183 | }; 184 | 185 | export type EnsureAction = { 186 | action: { 187 | meta?: ActionMeta; 188 | name?: string; 189 | type: "ensure"; 190 | location: { 191 | regexp?: string; 192 | value?: string; 193 | }; 194 | }; 195 | }; 196 | 197 | export type RadioAction = { 198 | action: { 199 | meta?: ActionMeta; 200 | type: "radio"; 201 | form: { 202 | selector: string; 203 | constrains?: { 204 | required: boolean; 205 | }; 206 | value: string; 207 | }; 208 | }; 209 | }; 210 | 211 | export type GotoAction = { 212 | action: { 213 | meta?: ActionMeta; 214 | type: "goto"; 215 | url: string; 216 | }; 217 | }; 218 | 219 | export type ClearAction = { 220 | action: { 221 | meta?: ActionMeta; 222 | type: "clear"; 223 | selector: string; 224 | }; 225 | }; 226 | 227 | export type DumpAction = { 228 | action: { 229 | meta?: ActionMeta; 230 | type: "dump"; 231 | }; 232 | }; 233 | 234 | export type HoverAction = { 235 | action: { 236 | meta?: ActionMeta; 237 | type: "hover"; 238 | selector: string; 239 | } 240 | } 241 | 242 | export type FocusAction = { 243 | action: { 244 | meta?: ActionMeta; 245 | type: "focus"; 246 | selector: string; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /cli/src/bin/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // tslint:disable-next-line 4 | require("source-map-support").install(); 5 | 6 | import { default as cluster } from "cluster"; 7 | import { default as program } from "commander"; 8 | import { default as fs, PathLike } from "fs"; 9 | import { default as yaml } from "js-yaml"; 10 | import { default as path } from "path"; 11 | import { default as readdir } from "recursive-readdir"; 12 | import { 13 | ActionHandler, 14 | ActionName, 15 | BrowserType, 16 | ChromeHandler, 17 | run 18 | } from "../main"; 19 | import { convert } from "../util"; 20 | 21 | import { default as d } from "debug"; 22 | const debug = d("pprunner"); 23 | 24 | import os from "os"; 25 | const numCPUs = os.cpus().length; 26 | 27 | program 28 | .version("0.6.5") 29 | .option("-p, --path ", "cases root dir") 30 | .option("-i, --image-dir ", "screehshots dir") 31 | .option("-c, --css-dir ", "optimize css dir") 32 | .option("-h, --disable-headless", "disable headless mode") 33 | .option("--puppeteer-args ") 34 | .parse(process.argv); 35 | 36 | process.on("unhandledRejection", err => { 37 | // tslint:disable-next-line 38 | console.error(err); 39 | process.exit(1); 40 | }); 41 | 42 | type CliOptions = { 43 | parallel: number; 44 | path: string; 45 | } & RunningOptions; 46 | 47 | type RunningOptions = { 48 | puppeteerArgs: string[]; 49 | imageDir: string; 50 | cssDir: string 51 | targetScenarios: string[]; 52 | handlers: { [key in ActionName]: ActionHandler }; 53 | headlessFlag: boolean; 54 | browserType: BrowserType; 55 | }; 56 | 57 | async function main(pg) { 58 | debug(pg); 59 | const { parallel, path: caseDir, ...options } = prepare(pg); 60 | 61 | if (cluster.isMaster) { 62 | const files = ((await readdir( 63 | path.resolve(process.cwd(), caseDir) 64 | )) as string[]) 65 | .sort() 66 | .filter(f => { 67 | return f.endsWith("yaml") || f.endsWith("yml"); 68 | }); 69 | if (!parallel) { 70 | // single thread 71 | for (const f of files) { 72 | await pprun({ file: f, options }); 73 | } 74 | return; 75 | } 76 | // multi thread 77 | const pNum = Math.max(numCPUs, parallel); 78 | for (let i = 0; i < pNum; i++) { 79 | cluster.fork(); 80 | } 81 | const workerIds = Object.keys(cluster.workers); 82 | const workers = workerIds.map(id => cluster.workers[id]); 83 | 84 | let index = 0; 85 | let done = 0; 86 | workers.forEach(worker => { 87 | worker.on("message", message => { 88 | if (message === "done") { 89 | done++; 90 | } 91 | if (done === files.length) { 92 | // All files finished 93 | process.exit(); 94 | } 95 | const file = files[index++]; 96 | if (file) { 97 | worker.send({ file }); 98 | } 99 | }); 100 | }); 101 | return; 102 | } 103 | // worker 104 | process.send("ready"); 105 | process.on("message", async message => { 106 | await pprun({ file: message.file, options }); 107 | process.send("done"); 108 | }); 109 | } 110 | 111 | async function pprun({ 112 | file, 113 | options: { 114 | targetScenarios, 115 | handlers, 116 | imageDir, 117 | cssDir, 118 | headlessFlag, 119 | browserType, 120 | puppeteerArgs 121 | } 122 | }: { 123 | file: PathLike; 124 | options: RunningOptions; 125 | }) { 126 | const originalBuffer = fs.readFileSync(file); 127 | const originalYaml = originalBuffer.toString(); 128 | const convertedYaml = convert(originalYaml); 129 | 130 | const doc = yaml.safeLoad(convertedYaml); 131 | if (doc.skip) { 132 | console.log(`${file} skip...`); 133 | return; 134 | } 135 | 136 | if (doc.onlyBrowser && !doc.onlyBrowser.includes(browserType)) { 137 | console.log( 138 | `this scenario only browser ${doc.onlyBrowser} ${file} skip...` 139 | ); 140 | return; 141 | } 142 | 143 | if (!doc.name) { 144 | console.error(`scenario: ${file} must be set name prop`); 145 | return; 146 | } 147 | if (targetScenarios.length !== 0 && !targetScenarios.includes(doc.name)) { 148 | debug(`skip scenario ${file}`); 149 | return; 150 | } 151 | 152 | const args = ["--no-sandbox", "--disable-setuid-sandbox", ...puppeteerArgs]; 153 | 154 | const launchOption = { 155 | args, 156 | headless: headlessFlag, 157 | ignoreHTTPSErrors: true, 158 | defaultViewport: doc.defaultViewport 159 | }; 160 | 161 | await run({ 162 | browserType, 163 | handlers, 164 | imageDir, 165 | cssDir, 166 | launchOption, 167 | scenario: doc 168 | }); 169 | } 170 | 171 | function prepare(pg): CliOptions { 172 | const imageDir = path.resolve(process.cwd(), pg.imageDir); 173 | const cssDir = path.resolve(process.cwd(), pg.cssDir); 174 | 175 | const extensions = {}; 176 | if (pg.extensionDir && pg.extensionDir !== "") { 177 | const extensionsDir = path.resolve(process.cwd(), pg.extensionDir); 178 | const filenames = fs.readdirSync(extensionsDir); 179 | 180 | filenames.forEach(f => { 181 | const mod = require(path.resolve(extensionsDir, f)); 182 | if (!mod.name) { 183 | // tslint:disable-next-line 184 | console.error(`module: ${f} is invalid. required name`); 185 | } 186 | extensions[mod.name] = mod.handler; 187 | }); 188 | } 189 | 190 | const targetScenarios = 191 | pg.target && pg.target !== "" ? pg.target.split(",") : []; 192 | 193 | const handlers = { 194 | ...getHandlers(pg.browser), 195 | ...extensions 196 | }; 197 | 198 | const puppeteerArgs = 199 | pg.puppeteerArgs && pg.puppeteerArgs !== "" 200 | ? pg.puppeteerArgs.split(",") 201 | : []; 202 | 203 | return { 204 | browserType: pg.browser || "chrome", 205 | puppeteerArgs, 206 | handlers, 207 | headlessFlag: !pg.disableHeadless, 208 | imageDir, 209 | cssDir, 210 | parallel: pg.parallel, 211 | path: pg.path, 212 | targetScenarios 213 | }; 214 | } 215 | 216 | function getHandlers(browser: BrowserType) { 217 | const handlers = ChromeHandler; 218 | 219 | return { 220 | clear: handlers.clearHandler, 221 | click: handlers.clickHandler, 222 | ensure: handlers.ensureHandler, 223 | goto: handlers.gotoHandler, 224 | input: handlers.inputHandler, 225 | radio: handlers.radioHandler, 226 | screenshot: handlers.screenshotHandler, 227 | select: handlers.selectHandler, 228 | wait: handlers.waitHandler, 229 | dump: handlers.dumpHandler, 230 | hover: handlers.hoverHandler, 231 | focus: handlers.focusHandler 232 | }; 233 | } 234 | 235 | main(program); 236 | -------------------------------------------------------------------------------- /cli/src/handlers/chrome-handlers.ts: -------------------------------------------------------------------------------- 1 | import { default as assert } from "assert"; 2 | import { default as faker } from "faker"; 3 | import { default as pretty } from "pretty"; 4 | import { Page } from "puppeteer"; 5 | import { default as RandExp } from "randexp"; 6 | import { ActionHandler } from "../types"; 7 | 8 | export const inputHandler: ActionHandler<"input", "chrome"> = async ( 9 | page: Page, 10 | { action } 11 | ) => { 12 | const input = action.form; 13 | await page.waitForSelector(input.selector); 14 | 15 | if (input.value) { 16 | if (typeof input.value === "string") { 17 | await page.type(input.selector, input.value); 18 | 19 | return { meta: action.meta, value: input.value }; 20 | } 21 | 22 | if ("faker" in input.value) { 23 | const fake = faker.fake(`{{${input.value.faker}}}`); 24 | await page.type(input.selector, fake); 25 | 26 | return { meta: action.meta, value: fake }; 27 | } 28 | if ("date" in input.value) { 29 | const dateStr = `00${input.value.date}`; 30 | await page.type(input.selector, dateStr); 31 | 32 | return { meta: action.meta, value: dateStr }; 33 | } 34 | throw new Error(`unknown input action ${action}`); 35 | } 36 | if (input.constrains && input.constrains.regexp) { 37 | const regex = new RegExp(input.constrains.regexp); 38 | 39 | const randex = new RandExp(regex); 40 | randex.defaultRange.subtract(32, 126); 41 | randex.defaultRange.add(0, 65535); 42 | 43 | const value = randex.gen(); 44 | 45 | await page.type(input.selector, value); 46 | 47 | return { meta: action.meta, value }; 48 | } 49 | 50 | throw new Error(`unknown input action ${action}`); 51 | }; 52 | 53 | export const waitHandler: ActionHandler<"wait", "chrome"> = async ( 54 | page: Page, 55 | { action } 56 | ) => { 57 | await page.waitFor(action.duration); 58 | return { meta: action.meta, duration: action.duration }; 59 | }; 60 | 61 | export const clickHandler: ActionHandler<"click", "chrome"> = async ( 62 | page: Page, 63 | { action } 64 | ) => { 65 | await page.waitForSelector(action.selector); 66 | 67 | if (action.emulateMouse) { 68 | const elem = await page.$(action.selector); 69 | const rect = await page.evaluate(el => { 70 | const { top, left, bottom, right } = el.getBoundingClientRect(); 71 | return { top, left, bottom, right }; 72 | }, elem); 73 | 74 | const center = (u, d) => { 75 | return (u - d) / 2 + d; 76 | }; 77 | 78 | console.log(rect); 79 | await page.mouse.click( 80 | center(rect.right, rect.left), 81 | center(rect.bottom, rect.top) 82 | ); 83 | 84 | return { meta: action.meta }; 85 | } 86 | 87 | if (!action.avoidClear) { 88 | await page.tap("body"); 89 | } 90 | 91 | if (action.navigation) { 92 | await Promise.all([ 93 | page.waitForNavigation(), 94 | page.$eval(action.selector, s => (s as any).click()) 95 | ]); 96 | } else { 97 | await page.$eval(action.selector, s => (s as any).click()); 98 | } 99 | 100 | return { meta: action.meta }; 101 | }; 102 | 103 | export const radioHandler: ActionHandler<"radio", "chrome"> = async ( 104 | page: Page, 105 | { action } 106 | ) => { 107 | await page.waitForSelector(action.form.selector); 108 | await page.$eval(`${action.form.selector}[value="${action.form.value}"]`, s => 109 | (s as any).click() 110 | ); 111 | 112 | return { meta: action.meta, value: action.form.value }; 113 | }; 114 | 115 | export const selectHandler: ActionHandler<"select", "chrome"> = async ( 116 | page: Page, 117 | { action } 118 | ) => { 119 | const select = action.form; 120 | await page.waitForSelector(select.selector); 121 | const v = select.constrains && select.constrains.values; 122 | if (v && v.length > 0) { 123 | await page.select( 124 | select.selector, 125 | `${v[Math.floor(Math.random() * v.length)]}` 126 | ); 127 | return { meta: action.meta, value: v }; 128 | } 129 | const value = await page.evaluate(selector => { 130 | return document.querySelector(selector).children[1].value; 131 | }, select.selector); 132 | await page.select(select.selector, `${value}`); 133 | 134 | return { meta: action.meta, value }; 135 | }; 136 | 137 | export const ensureHandler: ActionHandler<"ensure", "chrome"> = async ( 138 | page: Page, 139 | { action } 140 | ) => { 141 | if (!action.location) { 142 | return { meta: action.meta, ensure: false }; 143 | } 144 | const url = await page.url(); 145 | 146 | if (action.location.value) { 147 | assert.strictEqual( 148 | url, 149 | action.location.value, 150 | `location check failed: must be ${action.location.value}, but: ${url}` 151 | ); 152 | } 153 | 154 | if (action.location.regexp) { 155 | const regexp = new RegExp(action.location.regexp); 156 | assert( 157 | regexp.test(url), 158 | `location check failed: must be ${action.location.regexp}, but: ${url}` 159 | ); 160 | } 161 | return { meta: action.meta, ensure: true }; 162 | }; 163 | 164 | export const screenshotHandler: ActionHandler<"screenshot", "chrome"> = async ( 165 | page: Page, 166 | { action }, 167 | { imageDir, browserType } 168 | ) => { 169 | const filename = action.name; 170 | const now = Date.now(); 171 | const fullpath = `${imageDir}/${browserType}-${now}-${filename}.png`; 172 | await page.screenshot({ 173 | fullPage: action.fullPage, 174 | path: fullpath 175 | }); 176 | 177 | return { meta: action.meta, value: fullpath }; 178 | }; 179 | 180 | export const gotoHandler: ActionHandler<"goto", "chrome"> = async ( 181 | page: Page, 182 | { action } 183 | ) => { 184 | await Promise.all([page.waitForNavigation(), page.goto(action.url)]); 185 | return { meta: action.meta, value: action.url }; 186 | }; 187 | 188 | export const clearHandler: ActionHandler<"clear", "chrome"> = async ( 189 | page: Page, 190 | { action } 191 | ) => { 192 | await page.waitForSelector(action.selector); 193 | await page.click(action.selector, { clickCount: 3 }); 194 | await page.keyboard.press("Backspace"); 195 | 196 | return { meta: action.meta }; 197 | }; 198 | 199 | export const dumpHandler: ActionHandler<"dump", "chrome"> = async ( 200 | page: Page, 201 | { action } 202 | ) => { 203 | const bodyHTML = await page.evaluate(() => document.body.innerHTML); 204 | console.log(pretty(bodyHTML)); 205 | 206 | return { meta: action.meta, body: bodyHTML }; 207 | }; 208 | 209 | export const hoverHandler: ActionHandler<"hover", "chrome"> = async ( 210 | page: Page, 211 | { action } 212 | ) => { 213 | await page.hover(action.selector); 214 | 215 | return { meta: action.meta }; 216 | }; 217 | 218 | export const focusHandler: ActionHandler<"hover", "chrome"> = async ( 219 | page: Page, 220 | { action } 221 | ) => { 222 | await page.focus(action.selector); 223 | 224 | return { meta: action.meta }; 225 | }; 226 | -------------------------------------------------------------------------------- /cli/src/handlers/__tests__/chrome-handlers.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "power-assert"; 2 | import { 3 | clickHandler, 4 | ensureHandler, 5 | inputHandler, 6 | radioHandler, 7 | selectHandler, 8 | waitHandler 9 | } from "../chrome-handlers"; 10 | 11 | describe("inputHandler", () => { 12 | test("input.value", done => { 13 | return inputHandler( 14 | { 15 | type: async (selector, value) => { 16 | assert.strictEqual(selector, "#test"); 17 | assert.strictEqual(value, "test"); 18 | done(); 19 | }, 20 | waitForSelector: async selector => { 21 | assert.strictEqual(selector, "#test"); 22 | } 23 | } as any, 24 | { 25 | action: { 26 | form: { 27 | selector: "#test", 28 | value: "test" 29 | } 30 | } 31 | } as any 32 | ); 33 | }); 34 | test("input.value.faker", done => { 35 | return inputHandler( 36 | { 37 | type: async (selector, value) => { 38 | assert.strictEqual(selector, "#test"); 39 | assert(value); 40 | done(); 41 | }, 42 | waitForSelector: async selector => { 43 | assert.strictEqual(selector, "#test"); 44 | } 45 | } as any, 46 | { 47 | action: { 48 | form: { 49 | selector: "#test", 50 | value: { 51 | faker: "name.lastName" 52 | } 53 | } 54 | } 55 | } as any 56 | ); 57 | }); 58 | test("input.value.faker", done => { 59 | return inputHandler( 60 | { 61 | type: async (selector, value) => { 62 | assert.strictEqual(selector, "#test"); 63 | assert(value); 64 | done(); 65 | }, 66 | waitForSelector: async selector => { 67 | assert.strictEqual(selector, "#test"); 68 | } 69 | } as any, 70 | { 71 | action: { 72 | form: { 73 | selector: "#test", 74 | value: { 75 | faker: "name.lastName" 76 | } 77 | } 78 | } 79 | } as any 80 | ); 81 | }); 82 | test("input.value.date", done => { 83 | return inputHandler( 84 | { 85 | type: async (selector, value) => { 86 | assert.strictEqual(selector, "#test"); 87 | assert(value); 88 | done(); 89 | }, 90 | waitForSelector: async selector => { 91 | assert.strictEqual(selector, "#test"); 92 | } 93 | } as any, 94 | { 95 | action: { 96 | form: { 97 | selector: "#test", 98 | value: { 99 | date: "2016/01/02" 100 | } 101 | } 102 | } 103 | } as any 104 | ); 105 | }); 106 | test("input.constraint", done => { 107 | return inputHandler( 108 | { 109 | type: async (selector, value) => { 110 | assert.strictEqual(selector, "#test"); 111 | assert.strictEqual(value, "test"); 112 | done(); 113 | }, 114 | waitForSelector: async selector => { 115 | assert.strictEqual(selector, "#test"); 116 | } 117 | } as any, 118 | { 119 | action: { 120 | form: { 121 | constrains: { 122 | regexp: /test/ 123 | }, 124 | selector: "#test" 125 | } 126 | } 127 | } as any 128 | ); 129 | }); 130 | }); 131 | 132 | describe("waitHandler", () => { 133 | test("wait", done => { 134 | return waitHandler( 135 | { 136 | waitFor: async duration => { 137 | assert.strictEqual(duration, 100); 138 | done(); 139 | } 140 | } as any, 141 | { 142 | action: { 143 | duration: 100 144 | } 145 | } as any 146 | ); 147 | }); 148 | }); 149 | 150 | describe("clickHandler", () => { 151 | test("waitForSelector", done => { 152 | return clickHandler( 153 | { 154 | waitForSelector: async selector => { 155 | assert.strictEqual(selector, "#test"); 156 | }, 157 | tap: async selector => { 158 | assert.strictEqual(selector, "body"); 159 | }, 160 | $eval: async () => { 161 | done(); 162 | } 163 | } as any, 164 | { 165 | action: { 166 | selector: "#test" 167 | } 168 | } as any 169 | ); 170 | }); 171 | }); 172 | 173 | describe("radioHandler", () => { 174 | test("form.value", done => { 175 | return radioHandler( 176 | { 177 | $eval: async selectorWithValue => { 178 | assert.strictEqual(selectorWithValue, '#test[value="test"]'); 179 | done(); 180 | }, 181 | waitForSelector: async selector => { 182 | assert.strictEqual(selector, "#test"); 183 | } 184 | } as any, 185 | { 186 | action: { 187 | form: { 188 | selector: "#test", 189 | value: "test" 190 | } 191 | } 192 | } as any 193 | ); 194 | }); 195 | }); 196 | 197 | describe("selectHandler", () => { 198 | test("constrains.values(length=1)", done => { 199 | return selectHandler( 200 | { 201 | select: async () => { 202 | done(); 203 | }, 204 | evaluate: async (fn, selector) => { 205 | assert.strictEqual(selector, "#test"); 206 | }, 207 | waitForSelector: async selector => { 208 | assert.strictEqual(selector, "#test"); 209 | } 210 | } as any, 211 | { 212 | action: { 213 | form: { 214 | selector: "#test" 215 | } 216 | } 217 | } as any 218 | ); 219 | }); 220 | test("constrains.values(length=2)", done => { 221 | return selectHandler( 222 | { 223 | select: async (selector, value) => { 224 | assert.strictEqual(selector, "#test"); 225 | assert(value); 226 | done(); 227 | }, 228 | waitForSelector: async selector => { 229 | assert.strictEqual(selector, "#test"); 230 | } 231 | } as any, 232 | { 233 | action: { 234 | form: { 235 | constrains: { 236 | values: [1, 2] 237 | }, 238 | selector: "#test" 239 | } 240 | } 241 | } as any 242 | ); 243 | }); 244 | }); 245 | 246 | describe("ensureHandler", () => { 247 | test("location.value", () => { 248 | return ensureHandler( 249 | { 250 | url: async () => "http://test.com" 251 | } as any, 252 | { 253 | action: { 254 | location: { 255 | value: "http://test.com" 256 | } 257 | } 258 | } as any 259 | ); 260 | }); 261 | test("location.regexp", () => { 262 | return ensureHandler( 263 | { 264 | url: async () => "http://test.com/109" 265 | } as any, 266 | { 267 | action: { 268 | location: { 269 | regexp: /http:\/\/test.com\/[0-9]+/ 270 | } 271 | } 272 | } as any 273 | ); 274 | }); 275 | }); 276 | -------------------------------------------------------------------------------- /cli/src/run.ts: -------------------------------------------------------------------------------- 1 | import { default as fs, PathLike } from "fs"; 2 | import { default as produce } from "immer"; 3 | import { reduce } from "p-iteration"; 4 | import { default as puppeteer, LaunchOptions } from "puppeteer"; 5 | import postcss from "postcss" 6 | import { 7 | Action, 8 | ActionName, 9 | BrowserEngine, 10 | BrowserPage, 11 | Scenario 12 | } from "./main"; 13 | import { ActionHandler, BrowserType, Context } from "./types"; 14 | 15 | export type RunnerOptions = { 16 | browserType: BrowserType; 17 | scenario: Scenario; 18 | imageDir: PathLike; 19 | cssDir: PathLike; 20 | launchOption?: LaunchOptions; 21 | handlers: { [key in ActionName]: ActionHandler }; 22 | }; 23 | 24 | async function getBrowser( 25 | opts 26 | ): Promise { 27 | return puppeteer.launch(opts); 28 | } 29 | 30 | async function getPage( 31 | browser: BrowserEngine 32 | ): Promise { 33 | return (browser as puppeteer.Browser).newPage(); 34 | } 35 | 36 | function convertUrl(url) { 37 | const splitUrl = url 38 | .replace(/\#.*$/, '') 39 | .replace(/\?.*$/, '') 40 | .replace('.css', '') 41 | .split('/') 42 | .filter(e => Boolean(e)); 43 | return `${splitUrl[splitUrl.length - 1]}${url.match(/\?.*$/) ? url.match(/\?.*$/)[0] : ''}.css`; 44 | } 45 | 46 | function removeUnusedLines(fileCoverageMap, cssDir) { 47 | fileCoverageMap.forEach(fileCoverage => { 48 | const { fileName, coverage } = fileCoverage; 49 | new Promise(() => { 50 | fs.writeFile(`/${cssDir}/${fileName}`, coverage, err => { 51 | if (err) { 52 | return 53 | } 54 | }); 55 | }); 56 | }); 57 | } 58 | 59 | const isNodeUnneeded = (node) => { 60 | if (["root", "decl"].includes(node.type)) { 61 | return false; 62 | } 63 | 64 | if (node.type === "atrule" && node.name === "font-face") { 65 | return false; 66 | } 67 | 68 | return true 69 | }; 70 | 71 | const removeUnusedCSS = coverage => { 72 | let root 73 | const { text, ranges } = coverage 74 | const source = coverage.ranges.map((range, index) => { 75 | let code = ''; 76 | const prevRange = ranges[index - 1]; 77 | if (index === 0 && range.start > 0) { 78 | const comment = text 79 | .slice(0, range.start) 80 | .match(/(\/\*([\s\S]*?)\*\/)|(\/\/(.*)$)/gm); 81 | const annotation = text.slice(0, range.start).match(/@.*?;/gm); 82 | 83 | if (annotation) { 84 | code = code + annotation[0] + '\n'; 85 | } 86 | if (comment) { 87 | code = code + comment[0] + '\n'; 88 | } 89 | } 90 | if (prevRange) { 91 | const comment = text 92 | .slice(prevRange.end, range.start) 93 | .match(/(\/\*([\s\S]*?)\*\/)|(\/\/(.*)$)/gm); 94 | return comment 95 | ? code + 96 | comment[0] + 97 | '\n' + 98 | text.slice(range.start, range.end) + 99 | '\n' 100 | : code + text.slice(range.start, range.end) + '\n'; 101 | } else { 102 | return code + text.slice(range.start, range.end) + '\n'; 103 | } 104 | }) 105 | .join('\n') 106 | 107 | try{ 108 | root = postcss.parse(coverage.text); 109 | root.walk(node => { 110 | if (isNodeUnneeded(node)) { 111 | node.remove(); 112 | } 113 | }); 114 | } catch { 115 | return source 116 | } 117 | 118 | return root.toString() + source; 119 | }; 120 | 121 | export async function run({ 122 | browserType, 123 | scenario, 124 | handlers, 125 | imageDir, 126 | cssDir, 127 | launchOption 128 | }: RunnerOptions) { 129 | const browser = await getBrowser(launchOption); 130 | 131 | const page = await getPage(browser); 132 | 133 | if (scenario.userAgent) { 134 | await page.setUserAgent(scenario.userAgent); 135 | } 136 | 137 | let context: Context = { 138 | info: { 139 | options: { browserType, scenario, imageDir, cssDir, launchOption, handlers, }, 140 | name: scenario.name 141 | }, 142 | currentIteration: 0, 143 | precondition: { steps: [] }, 144 | iterations: [{ steps: [] }], 145 | postcondition: { steps: [] } 146 | }; 147 | 148 | const errorHandler = async (ctx: Context) => { 149 | if (!ctx.error) { 150 | return; 151 | } 152 | const screenshotHandler = handlers.screenshot; 153 | await screenshotHandler( 154 | page, 155 | { 156 | action: { 157 | type: "screenshot", 158 | name: "error", 159 | fullPage: true 160 | } 161 | }, 162 | { 163 | imageDir, 164 | browserType, 165 | context: ctx 166 | } as any 167 | ); 168 | 169 | const dumpHandler = handlers.dump; 170 | await dumpHandler(page, { action: { type: "dump" } }); 171 | }; 172 | 173 | try { 174 | const precondition = scenario.precondition; 175 | await (page as puppeteer.Page).coverage.startCSSCoverage() 176 | if (precondition) { 177 | console.log("precondition start."); 178 | context = await handlePreCondition(page, handlers, precondition, { 179 | imageDir, 180 | context, 181 | browserType 182 | }); 183 | await errorHandler(context); 184 | console.log("precondition done."); 185 | } 186 | 187 | console.log("main scenario start."); 188 | 189 | if (!context.error) { 190 | context = await handleIteration(page, handlers, scenario, { 191 | imageDir, 192 | context, 193 | browserType 194 | }); 195 | 196 | await errorHandler(context); 197 | } 198 | console.log("main scenario end."); 199 | 200 | if (scenario.postcondition) { 201 | await handlePostCondition(page, handlers, scenario.postcondition, { 202 | imageDir, 203 | context, 204 | browserType 205 | }); 206 | } 207 | } finally { 208 | const [cssCoverage] = await Promise.all([(page as puppeteer.Page).coverage.stopCSSCoverage()]); 209 | const fileCoverageMap = cssCoverage 210 | .filter(entry => entry.ranges.length > 0) 211 | .map(entry => { 212 | const { url } = entry; 213 | return { 214 | url, 215 | fileName: convertUrl(url), 216 | coverage: removeUnusedCSS(entry) 217 | }; 218 | }); 219 | removeUnusedLines(fileCoverageMap, cssDir); 220 | await browser.close(); 221 | } 222 | } 223 | 224 | type ContextReducer = (ctx: Context, res: any) => Context; 225 | 226 | export async function handlePreCondition( 227 | page: BrowserPage, 228 | handlers: { [key in ActionName]: ActionHandler }, 229 | condition: { url?: string; steps: Action[] }, 230 | { 231 | imageDir, 232 | context, 233 | browserType 234 | }: { imageDir: PathLike; context: Context; browserType: T } 235 | ): Promise { 236 | if (condition.url) { 237 | await handlers.goto(page, { 238 | action: { type: "goto", url: condition.url } 239 | }); 240 | } 241 | return handleAction( 242 | 0, 243 | page, 244 | handlers, 245 | condition.steps, 246 | { 247 | imageDir, 248 | context, 249 | browserType 250 | }, 251 | (ctx, res) => { 252 | return produce(ctx, draft => { 253 | draft.precondition.steps.push(res); 254 | }); 255 | } 256 | ); 257 | } 258 | 259 | export async function handlePostCondition( 260 | page: BrowserPage, 261 | handlers: { [key in ActionName]: ActionHandler }, 262 | condition: { url?: string; steps: Action[] }, 263 | { 264 | imageDir, 265 | context, 266 | browserType 267 | }: { imageDir: PathLike; context: Context; browserType: T } 268 | ): Promise { 269 | if (condition.url) { 270 | await handlers.goto(page, { 271 | action: { type: "goto", url: condition.url } 272 | }); 273 | } 274 | return handleAction( 275 | 0, 276 | page, 277 | handlers, 278 | condition.steps, 279 | { 280 | imageDir, 281 | context, 282 | browserType 283 | }, 284 | (ctx, res) => { 285 | return produce(ctx, draft => { 286 | draft.postcondition.steps.push(res); 287 | }); 288 | } 289 | ); 290 | } 291 | 292 | export async function handleIteration( 293 | page: BrowserPage, 294 | handlers: { [key in ActionName]: ActionHandler }, 295 | scenario: Scenario, 296 | { 297 | imageDir, 298 | browserType, 299 | context 300 | }: { imageDir: PathLike; browserType: T; context: Context } 301 | ): Promise { 302 | return reduce( 303 | Array.from({ length: 1 }), 304 | async (acc: Context, current: number, idx) => { 305 | await handlers.goto(page, { 306 | action: { type: "goto", url: scenario.url } 307 | }); 308 | return handleAction( 309 | idx + 1, 310 | page, 311 | handlers, 312 | scenario.steps, 313 | { 314 | context: acc, 315 | imageDir, 316 | browserType 317 | }, 318 | (ctx, res) => { 319 | return produce(ctx, draft => { 320 | if (!draft.iterations[idx]) { 321 | draft.iterations.push({ steps: [] }); 322 | } 323 | draft.iterations[idx].steps.push(res); 324 | }); 325 | } 326 | ); 327 | }, 328 | context 329 | ); 330 | } 331 | 332 | export async function handleAction( 333 | iteration: number, 334 | page: BrowserPage, 335 | handlers: { [key in ActionName]: ActionHandler }, 336 | steps: Action[], 337 | { 338 | imageDir, 339 | browserType, 340 | context 341 | }: { imageDir: PathLike; browserType: T; context: Context }, 342 | reducer: ContextReducer 343 | ): Promise { 344 | for (const step of steps) { 345 | const action = step.action; 346 | const handler = handlers[action.type] as ActionHandler; 347 | if (!handler) { 348 | throw new Error(`unknown action type: ${(action as any).type}`); 349 | } 350 | const res = await handler(page, { action } as any, { 351 | context: { 352 | ...context, 353 | currentIteration: iteration 354 | }, 355 | imageDir, 356 | browserType 357 | }).catch(e => { 358 | return { error: e }; 359 | }); 360 | 361 | if (res.error) { 362 | return { 363 | ...context, 364 | ...res 365 | }; 366 | } 367 | context = reducer(context, res); 368 | } 369 | return context; 370 | } 371 | -------------------------------------------------------------------------------- /cli/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------