├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── main.yml │ └── publish-on-tag.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── prettier.config.js ├── src ├── cli.ts ├── helpers.ts ├── recorder.ts └── runner.ts ├── test ├── getselector.spec.ts ├── helpers.spec.ts ├── public │ ├── iframe1.html │ ├── iframe2.html │ ├── iframes.html │ ├── index.html │ └── page2.html └── recorder.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | es6: true, 6 | }, 7 | 8 | parser: '@typescript-eslint/parser', 9 | 10 | plugins: ['jest', '@typescript-eslint', 'unicorn', 'import'], 11 | 12 | extends: ['plugin:prettier/recommended'], 13 | 14 | rules: { 15 | // Error if files are not formatted with Prettier correctly. 16 | 'prettier/prettier': 2, 17 | // syntax preferences 18 | quotes: [ 19 | 2, 20 | 'single', 21 | { 22 | avoidEscape: true, 23 | allowTemplateLiterals: true, 24 | }, 25 | ], 26 | 'spaced-comment': [ 27 | 2, 28 | 'always', 29 | { 30 | markers: ['*'], 31 | }, 32 | ], 33 | eqeqeq: [2], 34 | 'accessor-pairs': [ 35 | 2, 36 | { 37 | getWithoutSet: false, 38 | setWithoutGet: false, 39 | }, 40 | ], 41 | 'new-parens': 2, 42 | 'func-call-spacing': 2, 43 | 'prefer-const': 2, 44 | 45 | 'max-len': [ 46 | 2, 47 | { 48 | /* this setting doesn't impact things as we use Prettier to format 49 | * our code and hence dictate the line length. 50 | * Prettier aims for 80 but sometimes makes the decision to go just 51 | * over 80 chars as it decides that's better than wrapping. ESLint's 52 | * rule defaults to 80 but therefore conflicts with Prettier. So we 53 | * set it to something far higher than Prettier would allow to avoid 54 | * it causing issues and conflicting with Prettier. 55 | */ 56 | code: 200, 57 | comments: 90, 58 | ignoreTemplateLiterals: true, 59 | ignoreUrls: true, 60 | ignoreStrings: true, 61 | ignoreRegExpLiterals: true, 62 | }, 63 | ], 64 | // anti-patterns 65 | 'no-var': 2, 66 | 'no-with': 2, 67 | 'no-multi-str': 2, 68 | 'no-caller': 2, 69 | 'no-implied-eval': 2, 70 | 'no-labels': 2, 71 | 'no-new-object': 2, 72 | 'no-octal-escape': 2, 73 | 'no-self-compare': 2, 74 | 'no-shadow-restricted-names': 2, 75 | 'no-cond-assign': 2, 76 | 'no-debugger': 2, 77 | 'no-dupe-keys': 2, 78 | 'no-duplicate-case': 2, 79 | 'no-empty-character-class': 2, 80 | 'no-unreachable': 2, 81 | 'no-unsafe-negation': 2, 82 | radix: 2, 83 | 'valid-typeof': 2, 84 | 'no-unused-vars': [ 85 | 1, 86 | { 87 | args: 'none', 88 | vars: 'local', 89 | varsIgnorePattern: 90 | '([fx]?describe|[fx]?it|beforeAll|beforeEach|afterAll|afterEach)', 91 | }, 92 | ], 93 | 'no-implicit-globals': [2], 94 | 95 | // es2015 features 96 | 'require-yield': 2, 97 | 'template-curly-spacing': [2, 'never'], 98 | 99 | // ensure we don't have any it.only or describe.only in prod 100 | 'jest/no-focused-tests': 'error', 101 | 102 | // enforce the variable in a catch block is named error 103 | 'unicorn/catch-error-name': 'error', 104 | 105 | 'no-restricted-imports': [ 106 | 'error', 107 | { 108 | patterns: ['*Events'], 109 | paths: [ 110 | { 111 | name: 'mitt', 112 | message: 113 | 'Import Mitt from the vendored location: vendor/mitt/src/index.js', 114 | }, 115 | ], 116 | }, 117 | ], 118 | // 'import/extensions': ['error', 'ignorePackages'], 119 | }, 120 | overrides: [ 121 | { 122 | files: ['*.ts'], 123 | extends: [ 124 | 'plugin:@typescript-eslint/eslint-recommended', 125 | 'plugin:@typescript-eslint/recommended', 126 | ], 127 | rules: { 128 | 'no-unused-vars': 0, 129 | '@typescript-eslint/no-unused-vars': 1, 130 | 'func-call-spacing': 0, 131 | '@typescript-eslint/func-call-spacing': 2, 132 | semi: 0, 133 | '@typescript-eslint/semi': 2, 134 | '@typescript-eslint/no-empty-function': 0, 135 | '@typescript-eslint/no-use-before-define': 0, 136 | // We have to use any on some types so the warning isn't valuable. 137 | '@typescript-eslint/no-explicit-any': 0, 138 | // We don't require explicit return types on basic functions or 139 | // dummy functions in tests, for example 140 | '@typescript-eslint/explicit-function-return-type': 0, 141 | // We know it's bad and use it very sparingly but it's needed :( 142 | '@typescript-eslint/ban-ts-ignore': 0, 143 | '@typescript-eslint/ban-ts-comment': 0, 144 | /** 145 | * This is the default options (as per 146 | * https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/ban-types.md), 147 | * 148 | * Unfortunately there's no way to 149 | */ 150 | '@typescript-eslint/ban-types': [ 151 | 'error', 152 | { 153 | extendDefaults: true, 154 | types: { 155 | /* 156 | * Puppeteer's API accepts generic functions in many places so it's 157 | * not a useful linting rule to ban the `Function` type. This turns off 158 | * the banning of the `Function` type which is a default rule. 159 | */ 160 | Function: false, 161 | }, 162 | }, 163 | ], 164 | '@typescript-eslint/array-type': [ 165 | 2, 166 | { 167 | default: 'array-simple', 168 | }, 169 | ], 170 | }, 171 | }, 172 | { 173 | files: ['test-browser/**/*.js'], 174 | parserOptions: { 175 | sourceType: 'module', 176 | }, 177 | env: { 178 | es6: true, 179 | browser: true, 180 | es2020: true, 181 | }, 182 | }, 183 | ], 184 | }; 185 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Run tests # Give it any name 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | run-tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Build 15 | run: npm install && npm run build 16 | - name: Test 17 | run: npm test 18 | -------------------------------------------------------------------------------- /.github/workflows/publish-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: publish-on-tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Install dependencies 15 | run: npm install 16 | - name: Build 17 | run: npm run build 18 | - name: Publish 19 | env: 20 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} 21 | run: | 22 | npm config set '//wombat-dressing-room.appspot.com/:_authToken' '${NPM_TOKEN}' 23 | npm publish 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib 4 | .DS_store 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | coverage/ 3 | jest.config.js 4 | src/ 5 | test/ 6 | tsconfig.json 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://wombat-dressing-room.appspot.com/ 2 | access=public 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement (CLA). You (or your employer) retain the copyright to your 10 | contribution; this simply gives us permission to use and redistribute your 11 | contributions as part of the project. Head over to 12 | to see your current agreements on file or 13 | to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on using pull requests. 25 | 26 | ## Community Guidelines 27 | 28 | This project follows 29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). -------------------------------------------------------------------------------- /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 2017 Google Inc. 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Puppeteer Recorder (no longer maintained) [![npm @puppeteer/recorder package](https://img.shields.io/npm/v/@puppeteer/recorder)](https://www.npmjs.com/package/@puppeteer/recorder) 2 | 3 | 4 | 5 | > :warning: This package was a prototype for what can now be found in Chromium DevTools as the _Recorder_ experiment and will no longer be maintained. 6 | 7 | > Puppeteer is a Node.js library which provides a high-level API to control Chrome or Chromium over the [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). 8 | 9 | This repository allows you to record Puppeteer scripts by interacting with the browser. 10 | 11 | To start a new recording: 12 | 13 | ```bash 14 | npx @puppeteer/recorder [url] 15 | ``` 16 | 17 | Every interaction with the page will be recorded and printed to the console as a script, which you can run with puppeteer. 18 | __For now, this will download Chromium every time again. This has to be addressed on the puppeteer side. As a workaround, build this package locally (see [Setup](#setup)).__ 19 | 20 | ```js 21 | const {open, click, type, submit} = require('@puppeteer/recorder'); 22 | open('https://www.google.com/?hl=en', async () => { 23 | await click('ariaName/Search'); 24 | await type('ariaName/Search', 'calculator'); 25 | await click('ariaName/Google Search'); 26 | await click('ariaName/1'); 27 | await click('ariaName/plus'); 28 | await click('ariaName/2'); 29 | await click('ariaName/equals'); 30 | }); 31 | ``` 32 | 33 | ## Command line options 34 | 35 | - Pass `--output file.js` to write the output script to a file 36 | 37 | ## Architecture 38 | 39 | This project consists of three parts: 40 | - __Recorder__: A CLI script that starts a Chromium instance to record user interactions 41 | - __Runner__: An NPM package to abstract away the puppeteer details when running a recording 42 | - __Injected Script__: The recorder injects this script into the browser to collect user interactions 43 | 44 | ### Selectors 45 | 46 | The usual way of identifying elements within a website is to use a CSS selector. But a lot of websites use 47 | automatically generated class names that do not carry any semantic value, and change frequently. 48 | To increase the reliability of scripts generated with this tool, we query using the ARIA model. 49 | Instead of 50 | ``` 51 | #tsf > div:nth-child(2) > div.A8SBwf > div.RNNXgb > div > div.a4bIc > input 52 | ``` 53 | the same element can also be identified by 54 | ``` 55 | combobox[name="Search"] 56 | ``` 57 | 58 | ## Setup 59 | 60 | You can also check out this repository locally. 61 | To compile the _injected script_, the _recorder_ and the _runner_: 62 | 63 | ```bash 64 | npm install 65 | npm run build 66 | ``` 67 | 68 | To make the package available to run via `npx`: 69 | ```bash 70 | npm link 71 | ``` 72 | 73 | To run the package via `npx`: 74 | ```bash 75 | npx recorder [url] 76 | ``` 77 | 78 | When running a recorded script, make sure this package is available in the local `node_modules` folder: 79 | 80 | ```bash 81 | npm link @puppeteer/recorder 82 | ``` 83 | 84 | ## Debugging 85 | 86 | Use the runner with `DEBUG=1` to execute the script line by line. 87 | 88 | ## For maintainers 89 | 90 | ### How to publish new releases to npm 91 | 92 | 1. On the `main` branch, bump the version number in `package.json`: 93 | 94 | ```sh 95 | npm version patch -m 'Release v%s' 96 | ``` 97 | 98 | Instead of `patch`, use `minor` or `major` [as needed](https://semver.org/). 99 | 100 | Note that this produces a Git commit + tag. 101 | 102 | 1. Push the release commit and tag: 103 | 104 | ```sh 105 | git push # push the commit 106 | git push origin v0.1.2 # push the tag 107 | ``` 108 | 109 | Our CI then automatically publishes the new release to npm. 110 | 111 | ## Known limitations 112 | 113 | There are a number of known limitations: 114 | - ~~It's currently not possible to record interactions inside of [shadow doms](https://github.com/puppeteer/recorder/issues/4)~~ 115 | - It only records clicks, changes to text fields and form submits for now 116 | - It does not handle [Out-of-Process iframes](https://www.chromium.org/developers/design-documents/oop-iframes) ([See Bug](https://github.com/puppeteer/recorder/issues/20)) 117 | 118 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | module.exports = { 18 | preset: 'ts-jest', 19 | testEnvironment: 'node', 20 | roots: ['./test/'], 21 | collectCoverageFrom: ['src/*.{js,ts}'], 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@puppeteer/recorder", 3 | "version": "0.0.4", 4 | "description": "Interactively record puppeteer scripts", 5 | "main": "./lib/runner.js", 6 | "bin": { 7 | "@puppeteer/recorder": "./lib/cli.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/puppeteer/recorder.git" 12 | }, 13 | "dependencies": { 14 | "css.escape": "^1.5.1", 15 | "expect": "^26.1.0", 16 | "puppeteer": "^5.4.0" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^25.2.3", 20 | "@types/node": "^14.0.20", 21 | "@types/puppeteer": "^3.0.1", 22 | "@typescript-eslint/eslint-plugin": "^4.5.0", 23 | "@typescript-eslint/parser": "^4.5.0", 24 | "devtools-protocol": "0.0.820307", 25 | "eslint": "^7.11.0", 26 | "eslint-config-prettier": "^6.12.0", 27 | "eslint-plugin-import": "^2.22.0", 28 | "eslint-plugin-jest": "^24.1.0", 29 | "eslint-plugin-prettier": "^3.1.4", 30 | "eslint-plugin-unicorn": "^22.0.0", 31 | "express": "^4.17.1", 32 | "jest": "^26.0.1", 33 | "prettier": "^2.0.5", 34 | "ts-jest": "^26.0.0", 35 | "ts-node": "^8.10.2", 36 | "typescript": "^3.9.2" 37 | }, 38 | "scripts": { 39 | "test": "npm run build && jest", 40 | "build": "tsc", 41 | "dev": "ts-node ./src/cli.ts", 42 | "eslint": "([ \"$CI\" = true ] && eslint --ext js --ext ts --quiet -f codeframe . || eslint --ext js --ext ts .)", 43 | "eslint-fix": "eslint --ext js --ext ts --fix ." 44 | }, 45 | "author": "The Chromium Authors", 46 | "license": "Apache-2.0" 47 | } 48 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'es5', 4 | singleQuote: true, 5 | }; 6 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Copyright 2020 Google Inc. All rights reserved. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | /* eslint-disable @typescript-eslint/no-var-requires */ 20 | 21 | const [, , url] = process.argv; 22 | 23 | if (!url) { 24 | console.error('url required.'); 25 | process.exit(1); 26 | } else { 27 | require('./recorder') 28 | .default(url) 29 | .then((output) => { 30 | output.pipe(process.stdout); 31 | 32 | // Check if the output should also be written to a file 33 | const fileNameIndex = process.argv.indexOf('--output'); 34 | if (fileNameIndex !== -1) { 35 | if (fileNameIndex === process.argv.length) { 36 | throw new Error('Filename required when passing --output.'); 37 | } 38 | 39 | const fileName = process.argv[fileNameIndex + 1]; 40 | output.pipe(require('fs').createWriteStream(fileName, {})); 41 | } 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * This implementation heavily inspired by DevTools DOMPath implementation: 19 | * https://source.chromium.org/chromium/chromium/src/+/master:third_party/devtools-frontend/src/front_end/elements/DOMPath.js 20 | */ 21 | 22 | export function getParent(): Element { 23 | if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { 24 | return this.parentNode.host; 25 | } else { 26 | return this.parentElement; 27 | } 28 | } 29 | 30 | export function isSubmitButton(): boolean { 31 | return ( 32 | this.tagName === 'BUTTON' && 33 | (this as HTMLButtonElement).type === 'submit' && 34 | (this as HTMLButtonElement).form !== null 35 | ); 36 | } 37 | 38 | export function cssPath(): string { 39 | if (this.nodeType !== Node.ELEMENT_NODE) { 40 | return ''; 41 | } 42 | function idSelector(id: string) { 43 | return '#' + CSS.escape(id); 44 | } 45 | 46 | class Step { 47 | public readonly value: string; 48 | public readonly optimized: boolean; 49 | 50 | constructor(value: string, optimized: boolean) { 51 | this.value = value; 52 | this.optimized = optimized; 53 | } 54 | toString() { 55 | return this.value; 56 | } 57 | } 58 | 59 | function cssPathStep(node: Element, isTargetNode: boolean): Step { 60 | if (node.nodeType !== Node.ELEMENT_NODE) { 61 | return null; 62 | } 63 | const id = node.getAttribute('id'); 64 | if (id) { 65 | return new Step(idSelector(id), true); 66 | } 67 | const nodeNameLower = node.nodeName.toLowerCase(); 68 | if (['html', 'body', 'head'].includes(nodeNameLower)) { 69 | return new Step(nodeNameLower, true); 70 | } 71 | const nodeName = node.nodeName; 72 | const parent = node.parentNode; 73 | if (!parent || parent.nodeType === Node.DOCUMENT_NODE) { 74 | return new Step(nodeNameLower, true); 75 | } 76 | let needsClassNames = false; 77 | let needsNthChild = false; 78 | let ownIndex = -1; 79 | let elementIndex = -1; 80 | const siblings = parent.children; 81 | const ownClassNames = new Set(node.classList); 82 | for ( 83 | let i = 0; 84 | (ownIndex === -1 || !needsNthChild) && i < siblings.length; 85 | i++ 86 | ) { 87 | const sibling = siblings[i]; 88 | if (sibling.nodeType !== Node.ELEMENT_NODE) { 89 | continue; 90 | } 91 | elementIndex += 1; 92 | if (sibling === node) { 93 | ownIndex = elementIndex; 94 | continue; 95 | } 96 | if (sibling.nodeName !== nodeName) { 97 | continue; 98 | } 99 | needsClassNames = true; 100 | if (!ownClassNames.size) { 101 | needsNthChild = true; 102 | continue; 103 | } 104 | const siblingClassNames = new Set(sibling.classList); 105 | for (const siblingClass of siblingClassNames) { 106 | if (!ownClassNames.has(siblingClass)) { 107 | continue; 108 | } 109 | ownClassNames.delete(siblingClass); 110 | if (!ownClassNames.size) { 111 | needsNthChild = true; 112 | break; 113 | } 114 | } 115 | } 116 | let result = nodeNameLower; 117 | if ( 118 | isTargetNode && 119 | nodeName.toLowerCase() === 'input' && 120 | node.getAttribute('type') && 121 | !node.getAttribute('id') && 122 | !node.getAttribute('class') 123 | ) { 124 | result += `[type=${CSS.escape(node.getAttribute('type'))}]`; 125 | } 126 | if (needsNthChild) { 127 | result += `:nth-child(${ownIndex + 1})`; 128 | } else if (needsClassNames) { 129 | for (const className of ownClassNames) { 130 | result += '.' + CSS.escape(className); 131 | } 132 | } 133 | return new Step(result, false); 134 | } 135 | const steps = []; 136 | // eslint-disable-next-line @typescript-eslint/no-this-alias 137 | let currentNode = this; 138 | while (currentNode) { 139 | const step = cssPathStep(currentNode, currentNode === this); 140 | if (!step) { 141 | break; 142 | } 143 | steps.push(step); 144 | if (step.optimized) { 145 | break; 146 | } 147 | currentNode = currentNode.parentNode as Element; 148 | } 149 | steps.reverse(); 150 | return steps.join(' > '); 151 | } 152 | -------------------------------------------------------------------------------- /src/recorder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as puppeteer from 'puppeteer'; 18 | import { Readable } from 'stream'; 19 | import * as helpers from './helpers'; 20 | import * as protocol from 'devtools-protocol'; 21 | import { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; 22 | 23 | declare module 'puppeteer' { 24 | interface ElementHandle { 25 | _remoteObject: { objectId: string }; 26 | } 27 | interface Page { 28 | _client: puppeteer.CDPSession; 29 | } 30 | interface CDPSession { 31 | send( 32 | method: T, 33 | ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] 34 | ): Promise; 35 | } 36 | } 37 | 38 | interface RecorderOptions { 39 | wsEndpoint?: string; 40 | } 41 | 42 | async function getBrowserInstance(options: RecorderOptions) { 43 | if (options && options.wsEndpoint) { 44 | return puppeteer.connect({ browserWSEndpoint: options.wsEndpoint }); 45 | } else { 46 | return puppeteer.launch({ 47 | headless: false, 48 | defaultViewport: null, 49 | }); 50 | } 51 | } 52 | 53 | function escapeSelector(selector: string): string { 54 | return JSON.stringify(selector); 55 | } 56 | 57 | export async function isSubmitButton( 58 | client: puppeteer.CDPSession, 59 | objectId: string 60 | ): Promise { 61 | const isSubmitButtonResponse = await client.send('Runtime.callFunctionOn', { 62 | functionDeclaration: helpers.isSubmitButton.toString(), 63 | objectId, 64 | }); 65 | return isSubmitButtonResponse.result.value; 66 | } 67 | 68 | type AXNode = protocol.Protocol.Accessibility.AXNode; 69 | type CDPSession = puppeteer.CDPSession; 70 | 71 | // We check that a selector uniquely selects an element by querying the 72 | // selector and checking that all found elements are in the subtree of the 73 | // target. 74 | async function checkUnique( 75 | client: CDPSession, 76 | ignored: AXNode[], 77 | name?: string, 78 | role?: string 79 | ) { 80 | const { root } = await client.send('DOM.getDocument', { depth: 0 }); 81 | const checkName = await client.send('Accessibility.queryAXTree', { 82 | backendNodeId: root.backendNodeId, 83 | accessibleName: name, 84 | role: role, 85 | }); 86 | const ignoredIds = new Set(ignored.map((axNode) => axNode.backendDOMNodeId)); 87 | const checkNameMinusTargetTree = checkName.nodes.filter( 88 | (axNode) => !ignoredIds.has(axNode.backendDOMNodeId) 89 | ); 90 | return checkNameMinusTargetTree.length < 2; 91 | } 92 | 93 | export async function getSelector( 94 | client: puppeteer.CDPSession, 95 | objectId: string 96 | ): Promise { 97 | let currentObjectId = objectId; 98 | let prevName = ''; 99 | while (currentObjectId) { 100 | const queryResp = await client.send('Accessibility.queryAXTree', { 101 | objectId: currentObjectId, 102 | }); 103 | const targetNodes = queryResp.nodes; 104 | if (targetNodes.length === 0) break; 105 | const axNode = targetNodes[0]; 106 | const name: string = axNode.name.value; 107 | const role: string = axNode.role.value; 108 | // If the name does not include the child name, we have probably reached a 109 | // completely different entity so we give up and pick a CSS selector. 110 | if (!name.includes(prevName)) break; 111 | prevName = name; 112 | const uniqueName = await checkUnique(client, targetNodes, name); 113 | if (name && uniqueName) { 114 | return `aria/${name}`; 115 | } 116 | const uniqueNameRole = await checkUnique(client, targetNodes, name, role); 117 | if (name && role && uniqueNameRole) { 118 | return `aria/${name}[role="${role}"]`; 119 | } 120 | const { result } = await client.send('Runtime.callFunctionOn', { 121 | functionDeclaration: helpers.getParent.toString(), 122 | objectId: currentObjectId, 123 | }); 124 | currentObjectId = result.objectId; 125 | } 126 | const { result } = await client.send('Runtime.callFunctionOn', { 127 | functionDeclaration: helpers.cssPath.toString(), 128 | objectId, 129 | }); 130 | return result.value; 131 | } 132 | 133 | export default async ( 134 | url: string, 135 | options: RecorderOptions = {} 136 | ): Promise => { 137 | if (!url.startsWith('http')) { 138 | url = 'https://' + url; 139 | } 140 | 141 | const output = new Readable({ 142 | read(size) {}, 143 | }); 144 | output.setEncoding('utf8'); 145 | const browser = await getBrowserInstance(options); 146 | const page = await browser.pages().then((pages) => pages[0]); 147 | const client = page._client; 148 | page.on('domcontentloaded', async () => { 149 | await client.send('Debugger.enable', {}); 150 | await client.send('DOMDebugger.setEventListenerBreakpoint', { 151 | eventName: 'click', 152 | }); 153 | await client.send('DOMDebugger.setEventListenerBreakpoint', { 154 | eventName: 'change', 155 | }); 156 | await client.send('DOMDebugger.setEventListenerBreakpoint', { 157 | eventName: 'submit', 158 | }); 159 | // The heuristics we have for recording scrolling are quite fragile and 160 | // does not capture a reasonable set of scroll actions so we have decided 161 | // to disable it fow now 162 | /* 163 | await client.send('DOMDebugger.setEventListenerBreakpoint', { 164 | eventName: 'scroll', 165 | }); 166 | */ 167 | }); 168 | 169 | const findTargetId = async (localFrame, interestingClassNames: string[]) => { 170 | const event = localFrame.find((prop) => 171 | interestingClassNames.includes(prop.value.className) 172 | ); 173 | const eventProperties = await client.send('Runtime.getProperties', { 174 | objectId: event.value.objectId as string, 175 | }); 176 | const target = eventProperties.result.find( 177 | (prop) => prop.name === 'target' 178 | ); 179 | return target.value.objectId; 180 | }; 181 | 182 | const skip = async () => { 183 | await client.send('Debugger.resume', { terminateOnResume: false }); 184 | }; 185 | const resume = async () => { 186 | await client.send('Debugger.setSkipAllPauses', { skip: true }); 187 | await skip(); 188 | await client.send('Debugger.setSkipAllPauses', { skip: false }); 189 | }; 190 | 191 | const handleClickEvent = async (localFrame) => { 192 | const targetId = await findTargetId(localFrame, [ 193 | 'MouseEvent', 194 | 'PointerEvent', 195 | ]); 196 | // Let submit handle this case if the click is on a submit button. 197 | if (await isSubmitButton(client, targetId)) { 198 | return skip(); 199 | } 200 | const selector = await getSelector(client, targetId); 201 | if (selector) { 202 | addLineToPuppeteerScript(`await click(${escapeSelector(selector)});`); 203 | } else { 204 | console.log(`failed to generate selector`); 205 | } 206 | await resume(); 207 | }; 208 | 209 | const handleSubmitEvent = async (localFrame) => { 210 | const targetId = await findTargetId(localFrame, ['SubmitEvent']); 211 | const selector = await getSelector(client, targetId); 212 | if (selector) { 213 | addLineToPuppeteerScript(`await submit(${escapeSelector(selector)});`); 214 | } else { 215 | console.log(`failed to generate selector`); 216 | } 217 | await resume(); 218 | }; 219 | 220 | const handleChangeEvent = async (localFrame) => { 221 | const targetId = await findTargetId(localFrame, ['Event']); 222 | const targetValue = await client.send('Runtime.callFunctionOn', { 223 | functionDeclaration: 'function() { return this.value }', 224 | objectId: targetId, 225 | }); 226 | const value = targetValue.result.value; 227 | const selector = await getSelector(client, targetId); 228 | addLineToPuppeteerScript( 229 | `await type(${escapeSelector(selector)}, ${escapeSelector(value)});` 230 | ); 231 | await resume(); 232 | }; 233 | 234 | let scrollTimeout = null; 235 | const handleScrollEvent = async () => { 236 | if (scrollTimeout) return resume(); 237 | const prevScrollHeightResp = await client.send('Runtime.evaluate', { 238 | expression: 'document.scrollingElement.scrollHeight', 239 | }); 240 | const prevScrollHeight = prevScrollHeightResp.result.value; 241 | scrollTimeout = new Promise(function (resolve) { 242 | setTimeout(async () => { 243 | const currentScrollHeightResp = await client.send('Runtime.evaluate', { 244 | expression: 'document.scrollingElement.scrollHeight', 245 | }); 246 | const currentScrollHeight = currentScrollHeightResp.result.value; 247 | if (currentScrollHeight > prevScrollHeight) { 248 | addLineToPuppeteerScript(`await scrollToBottom();`); 249 | } 250 | scrollTimeout = null; 251 | resolve(); 252 | }, 1000); 253 | }); 254 | await resume(); 255 | }; 256 | 257 | client.on('Debugger.paused', async function ( 258 | pausedEvent: protocol.Protocol.Debugger.PausedEvent 259 | ) { 260 | const eventName = pausedEvent.data.eventName; 261 | const localFrame = pausedEvent.callFrames[0].scopeChain[0]; 262 | const { result } = await client.send('Runtime.getProperties', { 263 | objectId: localFrame.object.objectId, 264 | }); 265 | if (eventName === 'listener:click') { 266 | await handleClickEvent(result); 267 | } else if (eventName === 'listener:submit') { 268 | await handleSubmitEvent(result); 269 | } else if (eventName === 'listener:change') { 270 | await handleChangeEvent(result); 271 | } else if (eventName === 'listener:scroll') { 272 | await handleScrollEvent(); 273 | } else { 274 | await skip(); 275 | } 276 | }); 277 | 278 | let identation = 0; 279 | const addLineToPuppeteerScript = (line: string) => { 280 | const data = ' '.repeat(identation) + line; 281 | output.push(data + '\n'); 282 | }; 283 | 284 | page.evaluateOnNewDocument(() => { 285 | window.addEventListener('change', (event) => {}, true); 286 | window.addEventListener('click', (event) => {}, true); 287 | window.addEventListener('submit', (event) => {}, true); 288 | window.addEventListener('scroll', () => {}, true); 289 | }); 290 | 291 | // Setup puppeteer 292 | addLineToPuppeteerScript( 293 | `const {open, click, type, submit, expect, scrollToBottom} = require('@puppeteer/recorder');` 294 | ); 295 | addLineToPuppeteerScript(`open('${url}', {}, async (page) => {`); 296 | identation += 1; 297 | 298 | // Open the initial page 299 | await page.goto(url); 300 | 301 | // Add expectations for mainframe navigations 302 | page.on('framenavigated', async (frame: puppeteer.Frame) => { 303 | if (frame.parentFrame()) return; 304 | addLineToPuppeteerScript( 305 | `expect(page.url()).resolves.toBe(${escapeSelector(frame.url())});` 306 | ); 307 | }); 308 | 309 | async function close() { 310 | identation -= 1; 311 | addLineToPuppeteerScript(`});`); 312 | output.push(null); 313 | 314 | // In case we started the browser instance 315 | if (!options.wsEndpoint) { 316 | // Close it 317 | await browser.close(); 318 | } 319 | } 320 | 321 | // Finish the puppeteer script when the page is closed 322 | page.on('close', close); 323 | // Or if the user stops the script 324 | process.on('SIGINT', async () => { 325 | await close(); 326 | process.exit(); 327 | }); 328 | 329 | return output; 330 | }; 331 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as timers from 'timers'; 18 | import * as puppeteer from 'puppeteer'; 19 | import * as readline from 'readline'; 20 | import * as expect from 'expect'; 21 | 22 | export { expect }; 23 | 24 | declare const __dirname; 25 | 26 | const timeout = (t) => new Promise((cb) => timers.setTimeout(cb, t)); 27 | 28 | let browser, page; 29 | let delay = 100; 30 | const debug = process.env.DEBUG; 31 | 32 | interface RunnerOptions { 33 | delay: number; 34 | } 35 | 36 | async function beforeStep(...args) { 37 | console.log(...args); 38 | 39 | if (!debug) { 40 | await timeout(delay); 41 | return; 42 | } 43 | 44 | const rl = readline.createInterface({ 45 | input: process.stdin, 46 | output: process.stdout, 47 | }); 48 | 49 | await new Promise((resolve) => 50 | rl.question('Press enter to execute this step?', (ans) => { 51 | rl.close(); 52 | resolve(ans); 53 | }) 54 | ); 55 | } 56 | 57 | export async function open(url, options: RunnerOptions, cb) { 58 | delay = options.delay || 100; 59 | browser = await puppeteer.launch({ 60 | headless: false, 61 | defaultViewport: null, 62 | }); 63 | const pages = await browser.pages(); 64 | page = pages[0]; 65 | await page.goto(url); 66 | await timeout(1000); 67 | await cb(page, browser); 68 | await browser.close(); 69 | } 70 | 71 | export async function click(selector) { 72 | await beforeStep('click', selector); 73 | const element = await page.waitForSelector(selector, { visible: true }); 74 | await element.click(); 75 | } 76 | 77 | export async function type(selector, value) { 78 | await beforeStep('type', selector, value); 79 | const element = await page.waitForSelector(selector, { visible: true }); 80 | await element.click({ clickCount: 3 }); 81 | await element.press('Backspace'); 82 | await element.type(value); 83 | } 84 | 85 | export async function submit(selector) { 86 | await beforeStep('submit', selector); 87 | await page.$eval(selector, (form) => form.requestSubmit()); 88 | } 89 | 90 | export async function scrollToBottom() { 91 | await beforeStep('scrollToBottom'); 92 | await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight)); 93 | } 94 | -------------------------------------------------------------------------------- /test/getselector.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | /** 6 | * Copyright 2020 Google Inc. All rights reserved. 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | */ 20 | 21 | import * as puppeteer from 'puppeteer'; 22 | import { getSelector, isSubmitButton } from '../src/recorder'; 23 | 24 | declare module 'puppeteer' { 25 | interface ElementHandle { 26 | _remoteObject: { objectId: string }; 27 | } 28 | interface Page { 29 | _client: puppeteer.CDPSession; 30 | } 31 | } 32 | 33 | let browser: puppeteer.Browser, 34 | page: puppeteer.Page, 35 | client: puppeteer.CDPSession; 36 | 37 | describe('DOM', () => { 38 | beforeAll(async () => { 39 | browser = await puppeteer.launch({ defaultViewport: null, headless: true }); 40 | page = await browser.newPage(); 41 | client = page._client; 42 | }); 43 | 44 | afterAll(async () => { 45 | await browser.close(); 46 | }); 47 | 48 | describe('isSubmitButton', () => { 49 | it('should return true if the button is a submit button', async () => { 50 | await page.setContent(`
`); 62 | const element = await page.$('button'); 63 | const isSubmitCheck = await isSubmitButton( 64 | client, 65 | element._remoteObject.objectId 66 | ); 67 | expect(isSubmitCheck).toBe(false); 68 | }); 69 | }); 70 | 71 | describe('getSelector', () => { 72 | it('should return the aria name if it is available', async () => { 73 | await page.setContent( 74 | `
` 75 | ); 76 | 77 | const element = await page.$('button'); 78 | const selector = await getSelector( 79 | client, 80 | element._remoteObject.objectId 81 | ); 82 | expect(selector).toBe('aria/Hello World'); 83 | }); 84 | 85 | it('should return an aria name selector for the closest link or button', async () => { 86 | await page.setContent( 87 | `
` 88 | ); 89 | 90 | const element = await page.$('button'); 91 | const selector = await getSelector( 92 | client, 93 | element._remoteObject.objectId 94 | ); 95 | expect(selector).toBe('aria/Hello World'); 96 | }); 97 | 98 | it('should return name alone if it is unique', async () => { 99 | await page.setContent(``); 100 | 101 | const element = await page.$('button'); 102 | const selector = await getSelector( 103 | client, 104 | element._remoteObject.objectId 105 | ); 106 | expect(selector).toBe('aria/Hello World'); 107 | }); 108 | it('should include both name and role if name alone is not unique', async () => { 109 | await page.setContent( 110 | `

Hello World

` 111 | ); 112 | 113 | const element = await page.$('button'); 114 | const selector = await getSelector( 115 | client, 116 | element._remoteObject.objectId 117 | ); 118 | expect(selector).toBe('aria/Hello World[role="button"]'); 119 | }); 120 | 121 | it('should return an aria name selector for the closest link or button if the text is not an exact match', async () => { 122 | await page.setContent( 123 | `
` 124 | ); 125 | 126 | const element = await page.$('#button'); 127 | const selector = await getSelector( 128 | client, 129 | element._remoteObject.objectId 130 | ); 131 | expect(selector).toBe('aria/Hello World'); 132 | }); 133 | 134 | it('should return css selector if the element is not identifiable by an aria selector 1', async () => { 135 | await page.setContent( 136 | `
Hello World
` 137 | ); 138 | 139 | const element = await page.$('#button'); 140 | const selector = await getSelector( 141 | client, 142 | element._remoteObject.objectId 143 | ); 144 | expect(selector).toBe('#button'); 145 | }); 146 | 147 | it('should return css selector if the element is not identifiable by an aria selector 2', async () => { 148 | await page.setContent(`
Hello World
`); 149 | 150 | const element = await page.$('span'); 151 | const selector = await getSelector( 152 | client, 153 | element._remoteObject.objectId 154 | ); 155 | expect(selector).toBe('body > form > div > span'); 156 | }); 157 | 158 | it('should pierce shadow roots to get an aria name', async () => { 159 | await page.setContent( 160 | ` 161 | 175 | ` 176 | ); 177 | const link = await page.$('a'); 178 | const selector = await getSelector(client, link._remoteObject.objectId); 179 | expect(selector).toBe('aria/Hello World'); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /test/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | /** 6 | * Copyright 2020 Google Inc. All rights reserved. 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | */ 20 | 21 | import * as helpers from '../src/helpers'; 22 | require('css.escape'); 23 | 24 | const cssPath = (node: Node) => helpers.cssPath.bind(node)(); 25 | 26 | describe('CSS Path', () => { 27 | it('should return an empty path if the given node is not an element node', () => { 28 | const path = cssPath(document); 29 | expect(path).toBe(''); 30 | }); 31 | 32 | it('should return an id selector if the node has an id', () => { 33 | document.body.innerHTML = `
`; 34 | const node = document.querySelector('[data-id="test"]'); 35 | const path = cssPath(node); 36 | expect(path).toBe('#test'); 37 | }); 38 | 39 | it('should return a path until the document', () => { 40 | document.body.innerHTML = `

`; 41 | const node = document.querySelector('[data-id="test"]'); 42 | const path = cssPath(node); 43 | expect(path).toBe('body > div > p'); 44 | }); 45 | 46 | it('should ignore siblings that are not element nodes', () => { 47 | document.body.innerHTML = `
Hello World`; 48 | const node = document.querySelector('[data-id="test"]'); 49 | 50 | const path = cssPath(node); 51 | expect(path).toBe('body > div'); 52 | }); 53 | 54 | it('should index children with nth-child if there are siblings with the same tag name', () => { 55 | document.body.innerHTML = `

`; 56 | const node = document.querySelector('[data-id="test"]'); 57 | 58 | const path = cssPath(node); 59 | expect(path).toBe('body > div:nth-child(3)'); 60 | }); 61 | 62 | it('should not use nth-child if siblings with the same tag name are distinguishable by class name', () => { 63 | document.body.innerHTML = `
`; 64 | const node = document.querySelector('[data-id="test"]'); 65 | 66 | const path = cssPath(node); 67 | expect(path).toBe('body > div.test3'); 68 | }); 69 | 70 | it('should use nth-child if siblings with the same tag name are not distinguishable by class name', () => { 71 | document.body.innerHTML = `
`; 72 | const node = document.querySelector('[data-id="test"]'); 73 | 74 | const path = cssPath(node); 75 | expect(path).toBe('body > div:nth-child(2)'); 76 | }); 77 | 78 | it('should include the type for input elements', () => { 79 | document.body.innerHTML = ``; 80 | const node = document.querySelector('[data-id="test"]'); 81 | 82 | const path = cssPath(node); 83 | expect(path).toBe('body > input[type="email"]'); 84 | }); 85 | 86 | it('should escape properly', () => { 87 | document.body.innerHTML = ``; 88 | const node = document.querySelector('[data-id="test"]'); 89 | 90 | const path = cssPath(node); 91 | expect(path).toBe('body > input[type="foo\\"bar"]'); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/public/iframe1.html: -------------------------------------------------------------------------------- 1 |

iframe 1

-------------------------------------------------------------------------------- /test/public/iframe2.html: -------------------------------------------------------------------------------- 1 |

iframe 2

-------------------------------------------------------------------------------- /test/public/iframes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/public/index.html: -------------------------------------------------------------------------------- 1 |

Hello World!

2 | 3 | Test Link 4 | Go to iframes 5 | -------------------------------------------------------------------------------- /test/public/page2.html: -------------------------------------------------------------------------------- 1 |

Page 2

-------------------------------------------------------------------------------- /test/recorder.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | /** 6 | * Copyright 2020 Google Inc. All rights reserved. 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | */ 20 | 21 | import * as puppeteer from 'puppeteer'; 22 | import recorder from '../src/recorder'; 23 | import * as express from 'express'; 24 | 25 | import { Readable } from 'stream'; 26 | 27 | describe('Recorder', () => { 28 | let browser, page, app, url, server; 29 | 30 | async function getScriptFromStream(stream: Readable) { 31 | let script = ''; 32 | stream.on('data', (data) => { 33 | script += data; 34 | }); 35 | 36 | await new Promise((r) => stream.once('end', r)); 37 | 38 | const pattern = url.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); 39 | return script.replace(new RegExp(pattern, 'g'), '[url]'); 40 | } 41 | 42 | beforeAll(async () => { 43 | browser = await puppeteer.launch({ 44 | defaultViewport: null, 45 | headless: true, 46 | }); 47 | 48 | app = express(); 49 | app.use(express.static(__dirname + '/public')); 50 | return new Promise((resolve) => { 51 | server = app.listen(0, '127.0.0.1', () => { 52 | url = `http://localhost:${server.address().port}/`; 53 | resolve(); 54 | }); 55 | }); 56 | }); 57 | 58 | afterAll(async () => { 59 | await browser.close(); 60 | await server.close(); 61 | }); 62 | 63 | beforeEach(async () => { 64 | const prevPage = await browser.pages().then((pages) => pages[0]); 65 | page = await browser.newPage(); 66 | await prevPage.close(); 67 | }); 68 | 69 | it('should record a simple test', async () => { 70 | const output = await recorder(url, { 71 | wsEndpoint: browser.wsEndpoint(), 72 | }); 73 | 74 | await page.click('#button'); 75 | await browser.newPage(); 76 | await page.close(); 77 | 78 | await expect(getScriptFromStream(output)).resolves.toMatchInlineSnapshot(` 79 | "const {open, click, type, submit, expect, scrollToBottom} = require('@puppeteer/recorder'); 80 | open('[url]', {}, async (page) => { 81 | await click(\\"aria/Test Button\\"); 82 | }); 83 | " 84 | `); 85 | }); 86 | 87 | it('should output an url expectation when navigating', async () => { 88 | const output = await recorder(url, { 89 | wsEndpoint: browser.wsEndpoint(), 90 | }); 91 | 92 | await page.click('#link'); 93 | await browser.newPage(); 94 | await page.close(); 95 | 96 | await expect(getScriptFromStream(output)).resolves.toMatchInlineSnapshot(` 97 | "const {open, click, type, submit, expect, scrollToBottom} = require('@puppeteer/recorder'); 98 | open('[url]', {}, async (page) => { 99 | await click(\\"aria/Test Link\\"); 100 | expect(page.url()).resolves.toBe(\\"[url]page2.html\\"); 101 | }); 102 | " 103 | `); 104 | }); 105 | 106 | it('should output an url expectation only for the main frame when navigating', async () => { 107 | const output = await recorder(url, { 108 | wsEndpoint: browser.wsEndpoint(), 109 | }); 110 | 111 | await page.click('#iframes'); 112 | await page.click('#button'); 113 | await browser.newPage(); 114 | await page.close(); 115 | 116 | await expect(getScriptFromStream(output)).resolves.toMatchInlineSnapshot(` 117 | "const {open, click, type, submit, expect, scrollToBottom} = require('@puppeteer/recorder'); 118 | open('[url]', {}, async (page) => { 119 | await click(\\"aria/Go to iframes\\"); 120 | expect(page.url()).resolves.toBe(\\"[url]iframes.html\\"); 121 | await click(\\"aria/Simple Button\\"); 122 | }); 123 | " 124 | `); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "outDir": "./lib", 6 | "target": "ESNext", 7 | "moduleResolution": "node", 8 | "module": "CommonJS", 9 | "downlevelIteration": true, 10 | "types": ["node", "jest"] 11 | }, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------