├── .gitignore ├── .prettierignore ├── .travis.yml ├── .zipignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── logo.svg └── usage.png ├── intern.json ├── lib ├── devtools.html └── panel.html ├── manifest.json ├── package-lock.json ├── package.json ├── prettier.config.js ├── resources ├── manifestIcons │ ├── logo_128.png │ ├── logo_16.png │ ├── logo_32.png │ ├── logo_48.png │ └── logo_64.png └── statusBarIcons │ ├── clear.png │ ├── newTest.png │ ├── record_off.png │ ├── record_on.png │ └── save.png ├── src ├── EventProxy.ts ├── Recorder.ts ├── RecorderProxy.ts ├── background.ts ├── content.ts ├── devtools.ts └── types.d.ts ├── support ├── release.sh └── release.ts ├── tests ├── data │ ├── default.html │ ├── elements.html │ ├── frame.html │ ├── subframe.html │ └── superframe.html ├── filetypes.d.ts ├── integration │ ├── blank.ts │ ├── callback.ts │ ├── click.ts │ ├── doubleClick.ts │ ├── drag.ts │ ├── findDisplayed.ts │ ├── frame.ts │ ├── hotkey.ts │ ├── mouseMove.ts │ ├── navigation.ts │ ├── newTest.ts │ ├── tslint.json │ └── type.ts ├── support │ ├── mockChromeApi.ts │ ├── mockDomApi.ts │ ├── mockStorageApi.ts │ └── util.ts ├── tsconfig.build.json ├── tsconfig.json ├── unit │ ├── EventProxy.ts │ ├── Recorder.ts │ └── RecorderProxy.ts └── webpack.config.js ├── tsconfig.json ├── tslint.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | html-report 2 | node_modules 3 | build/ 4 | .DS_Store 5 | *.zip 6 | tests/_tests 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tests/integration/*.ts 2 | README.md 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - node 5 | addons: 6 | chrome: stable 7 | cache: 8 | directories: 9 | - node_modules 10 | script: npm run ci 11 | notifications: 12 | slack: 13 | secure: iIeBawvGzBDKfRHn/B6vu/W4CDJoWHxtJPgsZLm/N9nuEZj3OMC3MO5FwD56tXkf/ufh0/Ch+43DcpXVIoThILBP3/YPr2QyPyhGq6TOATU+i4Gp+em4MJonpeh370YIUUphYJOoMDenLQUs9L5mmyupphrVtcTpR7OX8uM8lMwgUKqKlKXQmXjDIgztyX0qeVJUEdKSTq29ZTFrzNJTZgTjXOXtNhDCtgk10rWu1EfWtqLDHG/79V+uTaV2Vi9X8W8JwQi5E1enf4fx6ABxVfGduDnnegqvDKbuedAx6ZbAzHHjOKYFuxqclODsVSjiJza9M0uDquEedRU81awvMw38Bz8QM70Mr9msKR64ZwGnMPJCpqasoIGpz45AmoSlx+iudMZAVvt5rVM77fYsaVdPIZa2pHi+IirSq2szcA3H2eoKzig8j//xd1AFYIde3LKZufMEZ0IFcBxb/VhX0pr/uFzlr9SbbN0B1tBfEcu6nZ+YmjNefInfytf8RL8LJUVCpDkJdMMb0uLUiJ+IAWezra+vvnDYH855aF+4Iut/24y/4aUrJBwzu9Ax7d4MGHxSipwVftyu7OZTiuWrnMxvbjVUdmy4Aeqnv7utrTqE9B7kpQu9Y/+2taVXboQ05SEgXn0NP6nZuAFvOFTSjbNYWy2lYHS5rcOIE+iyG9Y= 14 | -------------------------------------------------------------------------------- /.zipignore: -------------------------------------------------------------------------------- 1 | CONTRIBUTING.md 2 | README.md 3 | html-report 4 | html-report/* 5 | node_modules 6 | node_modules/* 7 | support 8 | support/* 9 | tests 10 | tests/* 11 | .travis.yml 12 | .git 13 | .git/* 14 | .gitignore 15 | .zipignore 16 | build 17 | build/* 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contribution guidelines 2 | ======================= 3 | 4 | ## Hi! Welcome! 5 | 6 | Thanks for taking a look at our contribution guidelines. This is a small open-source project and we’re always looking 7 | to get more people actively involved in its development and direction—even if you can’t send code! 8 | 9 | ## How to get help 10 | 11 | In order to keep the GitHub issue tracker focused on development tasks, our team prefers that **questions be asked on 12 | Stack Overflow or IRC instead of GitHub**. The [Getting help](https://theintern.github.io/intern/#getting-help) section 13 | of the user guide goes into detail about where and how to ask questions to get the best response! 14 | 15 | If you’re in a hurry, here are some direct links: 16 | 17 | * [Post a question to Stack Overflow](http://stackoverflow.com/questions/ask?tags=intern) 18 | * [Join #intern on Freenode](https://webchat.freenode.net/?channels=intern) 19 | 20 | ## Reporting bugs & feature requests 21 | 22 | For bugs, please [open a ticket](https://github.com/theintern/intern/issues/new?body=Description:%0A%0ASteps+to+reproduce:%0A%0A1.%20%E2%80%A6%0A2.%20%E2%80%A6%0A3.%20%E2%80%A6%0A%0AExpected%20result:%0AActual%20result:%0A%0AIntern%20version:%0A%0AAny%20additional%20information:), 23 | providing a description of the problem, reproduction steps, expected result, and actual result. It’s very hard for us 24 | to solve your problem without all of this information. 25 | 26 | For feature requests, just open a ticket describing what you’d like to see and we’ll try to figure out how it can 27 | happen! We (and all the other Intern users) would really appreciate it if you could also pitch in to actually implement 28 | the feature (maybe with some help from us?). 29 | 30 | It’s not that important, but if you can, try to post to the correct issue tracker for your problem: 31 | 32 | * [Dig Dug](https://github.com/theintern/digdug/issues) should be used for issues regarding downloading or starting 33 | service tunnels, or interacting with a service provider’s REST API 34 | * [Leadfoot](https://github.com/theintern/leadfoot/issues) should be used for issues with any of the functional 35 | testing APIs, including issues with cross-browser inconsistencies or unsupported Selenium environments 36 | * [Intern](https://github.com/theintern/intern/issues) for all other issues 37 | 38 | ## Getting involved 39 | 40 | Because Intern is a small project, *any* contribution you can make is much more impactful and much more appreciated 41 | than anything you could offer to big OSS projects that are already well-funded by big corporations like Google or 42 | Facebook. 43 | 44 | If you want to get involved with the sexy, sexy world of testing software, but aren’t sure where to start, come 45 | [talk to us on IRC](irc://irc.freenode.net/intern) or look through the list of 46 | [help-wanted tickets](https://github.com/theintern/intern/labels/help-wanted) for something that piques your interest. 47 | The development team is always happy to provide guidance to new contributors! 48 | 49 | If you’re not a coder (or you just don’t want to write code), we can still really use your help in other areas, like 50 | improving [documentation](https://github.com/theintern/intern/tree/gh-pages), or performing marketing and outreach, or 51 | helping other users on Stack Overflow or IRC, so get in touch if you’d be willing to help in any way! 52 | 53 | ## Submitting pull requests 54 | 55 | Like most open source projects, we require everyone to sign a 56 | [contributor license agreement](http://dojofoundation.org/about/claForm) before we can accept any non-trivial 57 | pull requests. This project belongs to the same foundation as other great OSS projects like Dojo, Grunt, lodash, and 58 | RequireJS, so one e-signature makes you eligible to contribute to all of these projects! 59 | 60 | Code should conform to our [code style guidelines](https://github.com/sitepen/.jshintrc). If possible and 61 | appropriate, updated tests should also be a part of your pull request. (If you’re having trouble writing tests, we can 62 | help you with them!) 63 | 64 | ## Advanced instructions for committers! 65 | 66 | * Please make sure to provide rigorous code review on new contributions 67 | * When in doubt, ask for a second review; don’t commit code that smells wrong just because it exists 68 | * Squash pull requests into a single commit using `git rebase -i` after a merge. Don’t use the shiny green button! 69 | No merge commits allowed! 70 | * Put `[ci skip]` at the end of commit messages for commits that do not modify any code (readme changes, etc.) 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | New BSD License 2 | 3 | © SitePen, Inc. http://sitepen.com 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | * Neither the name of The Intern nor the names of its contributors may 14 | be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY 21 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | Released under [JS Foundation CLA](https://js.foundation/CLA/). 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intern Recorder 2 | 3 | 4 |

Intern Recorder logo


5 | 6 | 7 | 8 | [![CI status](https://travis-ci.org/theintern/recorder.svg)](https://travis-ci.org/theintern/recorder) 9 | [![Intern](https://theintern.io/images/intern-v4.svg)](https://github.com/theintern/intern/) 10 | 11 | The Intern Recorder is a Chrome Developer Tools extension that assists in the 12 | creation of functional tests for Web applications by automatically recording 13 | user interaction with a browser into a format compatible with the Intern 14 | 4+ testing framework. 15 | 16 | 17 | 18 | * [Installation](#installation) 19 | * [Usage](#usage) 20 | * [Hotkeys](#hotkeys) 21 | * [Configuration](#configuration) 22 | * [Developing](#developing) 23 | * [Setup](#setup) 24 | * [Internal architecture](#internal-architecture) 25 | * [Debugging](#debugging) 26 | * [Support](#support) 27 | * [Special thanks](#special-thanks) 28 | * [Licensing](#licensing) 29 | 30 | 31 | 32 | ## Installation 33 | 34 | The latest version of Intern Recorder can be installed for free from the 35 | [Chrome Web Store]. 36 | 37 | ## Usage 38 | 39 | The Intern Recorder is a Dev Tools extension, so it can be accessed from the 40 | Dev Tools panel. On a tab you wish to record, open Dev Tools, then select the 41 | Intern tab. 42 | 43 | ![Intern UI](https://raw.githubusercontent.com/theintern/recorder/master/docs/usage.png) 44 | 45 | Start recording actions by clicking the **start/stop recording** button. The 46 | recorder automatically generates a single suite containing all the generated 47 | tests for the session. 48 | 49 | The **clear tests** button will remove all previously recorded actions/tests. 50 | 51 | The **new test** button will create a new test. 52 | 53 | The **save** button will save the generated test script to a file. 54 | 55 | ### Hotkeys 56 | 57 | The Recorder also includes configurable hotkeys that can be used to perform 58 | common operations during a test recording. These operations are: 59 | 60 | * **Pause/resume recorder**. This is equivalent to clicking the record button in 61 | Dev Tools. 62 | * **Insert callback**. This inserts a `then` command into the script containing an 63 | empty callback function. 64 | * **Insert move to current mouse position**. This inserts a `moveMouseTo` command 65 | into the script wherever the mouse is currently positioned. 66 | 67 | > 💡 The hotkeys only work when you are focused on the tab of the page 68 | being tested. Pressing the hotkeys when the Dev Tools window is focused will do 69 | nothing. 70 | 71 | > 💡 The default hotkeys may not work as expected on your system’s keyboard 72 | 73 | ### Configuration 74 | 75 | Currently, the only configuration available for the Intern Recorder are the 76 | hotkey combinations. Simply click in one of the input fields and press the key 77 | combination you’d like to use to configure hotkeys. Hotkey configuration is 78 | persisted to local storage. 79 | 80 | ## Developing 81 | 82 | ### Setup 83 | 84 | 1. Clone this repository 85 | 2. Run `npm install` and `npm build-watch`. This will start a build watcher 86 | that will update Intern Recorder as you make changes. 87 | 3. Opening the Extensions tab in Chrome (`chrome://extensions`) 88 | 4. Enable Developer mode with the toggle at the top of the page 89 | 5. Choose ‘LOAD UNPACKED’ and select the directory `/build` 90 | 91 | ### Internal architecture 92 | 93 | Chrome restricts which extension APIs are available to Dev Tools scripts, so 94 | the Recorder is designed using a multi-process architecture: 95 | 96 | ![Intern UI](https://theintern.github.io/recorder/images/architecture.svg) 97 | 98 | The recorder itself is maintained in the background script, which has access to 99 | the full Chrome extension API. The user interface is displayed from the Dev 100 | Tools page script and communicates with the recorder through a `chrome.runtime` 101 | messaging port. To intercept page interaction, the background script injects an 102 | event forwarding script into the browser tab that listens for various DOM 103 | events and passes them to the recorder through a second `chrome.runtime` 104 | messaging port. 105 | 106 | ### Debugging 107 | 108 | * Injected content (`content.ts`, `EventProxy.ts`): Errors and console 109 | statements will show up directly in Dev Tools for the page being recorded. 110 | * Background script (`background.ts`, `Recorder.ts`): Open the Chrome 111 | extensions tab, find Intern Recorder in the list of loaded extensions, and 112 | click the “background page” link next to “Inspect views”. This will open a 113 | new Dev Tools window for the background script. 114 | * Dev tools page (`devtools.html`, `devtools.ts`, `panel.html`, 115 | `RecorderProxy.ts`): Open Dev Tools, undock it (using the top right icon, 116 | next to Settings), choose the Intern tab, then open another Dev Tools window. 117 | The second Dev Tools window will be inspecting the first Dev Tools window. 118 | 119 | ## Support 120 | 121 | Any general questions about how to use Intern Recorder should be directed to 122 | [Stack Overflow](https://stackoverflow.com) (using the `intern` tag) or our 123 | [Gitter channel](https://gitter.im/theintern/intern). 124 | 125 | If you think you’ve found a bug or have a specific enhancement request, file an 126 | issue in the [issue tracker](https://github.com/theintern/recorder/issues). 127 | Please read the [contribution guidelines](./CONTRIBUTING.md) for more 128 | information. 129 | 130 | ## Special thanks 131 | 132 | A very special thanks to [Built](https://www.getbuilt.com/) for sponsoring the 133 | work to update Recorder for Intern 4! 134 | 135 | Continuing thanks to [SITA](https://www.sita.aero/) for sponsoring the first 136 | release of the Intern Recorder and making this tool possible. 137 | 138 | 139 | ## Licensing 140 | 141 | Intern Recorder is a JS Foundation project offered under the [New BSD](LICENSE) license. 142 | 143 | © [SitePen, Inc.](http://sitepen.com/) and its [contributors](https://github.com/theintern/recorder/graphs/contributors) 144 | 145 | 146 | [Chrome Web Store]: https://chrome.google.com/webstore/detail/intern-recorder/oalhlikaceknjlnmoombecafnmhbbgna 147 | [contribution guidelines]: ./CONTRIBUTING.md 148 | [main issue tracker]: https://github.com/theintern/intern/issues/new?body=Description:%0A%0ASteps+to+reproduce:%0A%0A1.%20%E2%80%A6%0A2.%20%E2%80%A6%0A3.%20%E2%80%A6%0A%0AExpected%20result:%0AActual%20result:%0A%0AIntern%20version:%0ARecorder%20version:%0A%0AAny%20additional%20information: 149 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/docs/usage.png -------------------------------------------------------------------------------- /intern.json: -------------------------------------------------------------------------------- 1 | { 2 | "suites": "tests/build/tests.js", 3 | "functionalSuites": "tests/_tests/**/*.js", 4 | "filterErrorStack": true, 5 | "reporters": [ 6 | { 7 | "name": "runner", 8 | "options": { 9 | "hidePassed": true, 10 | "hideSkipped": true 11 | } 12 | } 13 | ], 14 | "environments": { 15 | "browserName": "chrome", 16 | "chromeOptions": { 17 | "args": ["headless", "disable-gpu", "no-sandbox"] 18 | }, 19 | "fixSessionCapabilities": "no-detect" 20 | }, 21 | "tunnelOptions": { 22 | "drivers": [{ "name": "chrome", "version": "2.36" }] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/devtools.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 96 | 97 |
98 |
The generated test script will show up here once you begin recording.
99 |
100 |
101 |
Options
102 | 103 |
104 |
105 | 106 | 107 |
108 | 109 |
110 | 111 | 115 |
116 | 117 |
118 | 119 | 120 |
121 | 122 |
123 | 124 | 128 |
129 |
130 | 131 |

132 | If a custom attribute is provided, it will be used as the 133 | selector for any elements that have the custom attribute. 134 |

135 |
136 | 137 |
138 |
Hotkeys
139 | 140 |
141 |
142 | 143 | 144 |
145 | 146 |
147 | 148 | 149 |
150 | 151 |
152 | 153 | 154 |
155 |
156 |
157 |
158 |
159 | 160 | 161 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Intern Recorder", 4 | "description": 5 | "Creates functional tests for Intern based on interaction with a site from your browser.", 6 | "version": "2.0.1", 7 | "version_name": "2.0.1-pre", 8 | "minimum_chrome_version": "26.0", 9 | "devtools_page": "lib/devtools.html", 10 | "background": { 11 | "scripts": ["lib/Recorder.js", "lib/background.js"], 12 | "persistent": false 13 | }, 14 | "icons": { 15 | "16": "resources/manifestIcons/logo_16.png", 16 | "32": "resources/manifestIcons/logo_32.png", 17 | "48": "resources/manifestIcons/logo_48.png", 18 | "64": "resources/manifestIcons/logo_64.png", 19 | "128": "resources/manifestIcons/logo_128.png" 20 | }, 21 | "permissions": [ 22 | "downloads", 23 | "http://*/*", 24 | "https://*/*", 25 | "tabs", 26 | "webNavigation" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intern-recorder", 3 | "version": "2.0.1-pre", 4 | "description": "Intern Recorder. Creates functional tests for Intern based on interaction with a site from your browser.", 5 | "license": "BSD-3-Clause", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/theintern/recorder.git" 9 | }, 10 | "devDependencies": { 11 | "@theintern/dev": "~0.6.6", 12 | "@theintern/istanbul-loader": "~1.0.0-beta.2", 13 | "@types/chrome": "0.0.59", 14 | "@types/highlight.js": "~9.12.2", 15 | "commander": "~2.14.0", 16 | "concurrently": "~3.5.1", 17 | "copy-webpack-plugin": "~4.3.1", 18 | "highlight.js": "~9.12.0", 19 | "husky": "~0.15.0-rc.13", 20 | "intern": "~4.1.5", 21 | "lint-staged": "~7.0.0", 22 | "raw-loader": "~0.5.1", 23 | "rimraf": "~2.6.2", 24 | "ts-loader": "~3.4.0", 25 | "ts-node": "~4.1.0", 26 | "webpack": "~3.10.0" 27 | }, 28 | "bugs": "https://github.com/theintern/intern/issues", 29 | "keywords": [ 30 | "javascript", 31 | "test", 32 | "unit", 33 | "testing", 34 | "ci", 35 | "continuous integration", 36 | "bdd", 37 | "tdd", 38 | "xunit", 39 | "istanbul", 40 | "chai", 41 | "dojo", 42 | "toolkit", 43 | "selenium", 44 | "sauce labs", 45 | "code coverage", 46 | "recorder", 47 | "functional", 48 | "webdriver" 49 | ], 50 | "homepage": "http://theintern.io/", 51 | "scripts": { 52 | "build": "webpack", 53 | "build-tests": "webpack --config=tests/webpack.config.js && tsc -P tests/tsconfig.build.json", 54 | "watch-tests": "concurrently --kill-others-on-fail 'webpack --config=tests/webpack.config.js --watch' 'tsc -P tests/tsconfig.build.json --watch'", 55 | "clean": "rimraf 'tests/_tests' 'tests/build' 'build'", 56 | "test": "intern", 57 | "ci": "npm run build-tests && npm test" 58 | }, 59 | "dependencies": { 60 | "prettier": "~1.11.1" 61 | }, 62 | "husky": { 63 | "hooks": { 64 | "pre-commit": "lint-staged" 65 | } 66 | }, 67 | "lint-staged": { 68 | "{!(package|package-lock),}.json": [ 69 | "prettier --write", 70 | "git add" 71 | ], 72 | "*.md": [ 73 | "prettier --write", 74 | "git add" 75 | ], 76 | "**/*.{ts,tsx}": [ 77 | "prettier --write", 78 | "tslint", 79 | "git add" 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@theintern/dev/prettier.config'); 2 | -------------------------------------------------------------------------------- /resources/manifestIcons/logo_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/manifestIcons/logo_128.png -------------------------------------------------------------------------------- /resources/manifestIcons/logo_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/manifestIcons/logo_16.png -------------------------------------------------------------------------------- /resources/manifestIcons/logo_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/manifestIcons/logo_32.png -------------------------------------------------------------------------------- /resources/manifestIcons/logo_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/manifestIcons/logo_48.png -------------------------------------------------------------------------------- /resources/manifestIcons/logo_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/manifestIcons/logo_64.png -------------------------------------------------------------------------------- /resources/statusBarIcons/clear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/statusBarIcons/clear.png -------------------------------------------------------------------------------- /resources/statusBarIcons/newTest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/statusBarIcons/newTest.png -------------------------------------------------------------------------------- /resources/statusBarIcons/record_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/statusBarIcons/record_off.png -------------------------------------------------------------------------------- /resources/statusBarIcons/record_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/statusBarIcons/record_on.png -------------------------------------------------------------------------------- /resources/statusBarIcons/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theintern/recorder/6741ae3ef531e6308165dece8c8f5e31a3dd976d/resources/statusBarIcons/save.png -------------------------------------------------------------------------------- /src/EventProxy.ts: -------------------------------------------------------------------------------- 1 | import { Chrome } from './types'; 2 | 3 | const EVENT_TYPES = [ 4 | 'click', 5 | 'dblclick', 6 | 'mousedown', 7 | 'mouseup', 8 | 'mousemove', 9 | 'keydown', 10 | 'keyup' 11 | ]; 12 | 13 | export default class EventProxy { 14 | window: Window; 15 | document: Document; 16 | chrome: Chrome; 17 | lastMouseDown: { 18 | [button: number]: { event: MouseEvent; elements: Element[] }; 19 | }; 20 | getTarget: (element: ElementLike) => string; 21 | port: chrome.runtime.Port | null; 22 | _customAttr: string | undefined; 23 | 24 | constructor(window: Window, document: Document, chrome: Chrome) { 25 | this.window = window; 26 | this.document = document; 27 | this.chrome = chrome; 28 | this.lastMouseDown = {}; 29 | } 30 | 31 | connect() { 32 | const sendEvent = this.sendEvent.bind(this); 33 | const passEvent = this.passEvent.bind(this); 34 | 35 | this.window.addEventListener('message', passEvent, false); 36 | 37 | EVENT_TYPES.forEach(eventType => { 38 | this.document.addEventListener(eventType, sendEvent, true); 39 | }); 40 | 41 | if (this.port) { 42 | this.port.disconnect(); 43 | } 44 | 45 | this.port = this.chrome.runtime.connect(this.chrome.runtime.id, { 46 | name: 'eventProxy' 47 | }); 48 | const disconnect = () => { 49 | this.port!.onDisconnect.removeListener(disconnect); 50 | EVENT_TYPES.forEach(eventType => { 51 | this.document.removeEventListener(eventType, sendEvent, true); 52 | }); 53 | this.window.removeEventListener('message', passEvent, false); 54 | this.port = null; 55 | }; 56 | 57 | this.port.onDisconnect.addListener(disconnect); 58 | this.port.onMessage.addListener(message => { 59 | const { method, args } = <{ 60 | method: keyof EventProxy; 61 | args: any[]; 62 | }>message; 63 | if (!this[method]) { 64 | throw new Error( 65 | `Method "${method}" does not exist on RecorderProxy` 66 | ); 67 | } 68 | 69 | (this[method]!)(...(args || [])); 70 | }); 71 | } 72 | 73 | getElementTextPath(element: ElementLike) { 74 | const tagPrefix = `//${element.nodeName}`; 75 | 76 | const textValue = this.document.evaluate( 77 | 'normalize-space(string())', 78 | element, 79 | null, 80 | (this.window).XPathResult.STRING_TYPE, 81 | null 82 | ).stringValue; 83 | 84 | let path = `[normalize-space(string())="${textValue.replace( 85 | /"/g, 86 | '"' 87 | )}"]`; 88 | 89 | const matchingElements = this.document.evaluate( 90 | tagPrefix + path, 91 | this.document, 92 | null, 93 | (this.window).XPathResult.UNORDERED_NODE_ITERATOR_TYPE, 94 | null 95 | ); 96 | 97 | matchingElements.iterateNext(); 98 | const matchesMultipleElements = Boolean(matchingElements.iterateNext()); 99 | 100 | if (matchesMultipleElements) { 101 | // Ignore IDs because when the text strategy is being used it 102 | // typically means that IDs are not deterministic 103 | path = this.getElementXPath(element, true) + path; 104 | } else { 105 | path = tagPrefix + path; 106 | } 107 | 108 | return path; 109 | } 110 | 111 | getElementXPath(element?: ElementLike, ignoreId?: boolean) { 112 | const path = []; 113 | let node: ElementLike | DocumentLike | null | undefined = element; 114 | 115 | do { 116 | const el = node; 117 | 118 | if ( 119 | this._customAttr && 120 | el.hasAttribute && 121 | el.hasAttribute(this._customAttr) 122 | ) { 123 | const value = el.getAttribute(this._customAttr); 124 | path.unshift(`[${this._customAttr}="${value}"]`); 125 | 126 | // No need to continue to ascend since we found a unique root 127 | break; 128 | } else if (el.id && !ignoreId) { 129 | path.unshift('id("' + el.id + '")'); 130 | 131 | // No need to continue to ascend since we found a unique root 132 | break; 133 | } else if (el.parentNode) { 134 | const nodeName = el.nodeName; 135 | const hasNamedSiblings = Boolean( 136 | el.previousElementSibling || el.nextElementSibling 137 | ); 138 | // XPath is 1-indexed 139 | let index = 1; 140 | let sibling: ElementLike | null | undefined = el; 141 | 142 | if (hasNamedSiblings) { 143 | while ((sibling = sibling.previousElementSibling)) { 144 | if (sibling.nodeName === nodeName) { 145 | ++index; 146 | } 147 | } 148 | 149 | path.unshift(nodeName + '[' + index + ']'); 150 | } else { 151 | path.unshift(nodeName); 152 | } 153 | } else { 154 | // The root node -- add an empty string so the join will add a 155 | // '/' to the beginning of the path 156 | path.unshift(''); 157 | } 158 | } while ((node = node!.parentNode)); 159 | 160 | return path.join('/'); 161 | } 162 | 163 | passEvent(event: MessageEvent) { 164 | if ( 165 | !event.data || 166 | event.data.method !== 'recordEvent' || 167 | !event.data.detail 168 | ) { 169 | return; 170 | } 171 | 172 | const detail = event.data.detail; 173 | const frames: Window[] = this.window.frames; 174 | 175 | for (let i = 0; i < frames.length; ++i) { 176 | if (event.source === frames[i]) { 177 | detail.targetFrame.unshift(i); 178 | break; 179 | } 180 | } 181 | 182 | this.send(detail); 183 | } 184 | 185 | send(detail: any) { 186 | if (this.window !== this.window.top) { 187 | this.window.parent.postMessage( 188 | { 189 | method: 'recordEvent', 190 | detail: detail 191 | }, 192 | '*' 193 | ); 194 | } else { 195 | this.port!.postMessage({ 196 | method: 'recordEvent', 197 | args: [detail] 198 | }); 199 | } 200 | } 201 | 202 | sendEvent(event: MouseEvent & KeyboardEvent) { 203 | const lastMouseDown = this.lastMouseDown; 204 | let target; 205 | 206 | function isDragEvent() { 207 | return ( 208 | Math.abs( 209 | event.clientX - lastMouseDown[event.button].event.clientX 210 | ) > 5 || 211 | Math.abs( 212 | event.clientY - lastMouseDown[event.button].event.clientY 213 | ) > 5 214 | ); 215 | } 216 | 217 | if (event.type === 'click' && isDragEvent()) { 218 | return; 219 | } 220 | 221 | if (event.type === 'mousedown') { 222 | lastMouseDown[event.button] = { 223 | event, 224 | elements: this.document.elementsFromPoint( 225 | event.clientX, 226 | event.clientY 227 | ) 228 | }; 229 | } 230 | 231 | // When a user drags an element that moves with the mouse, the element will not be dragged in the recorded 232 | // output unless the final position of the mouse is recorded relative to an element that did not move 233 | if (event.type === 'mouseup') { 234 | target = (() => { 235 | // The nearest element to the target that was not also the nearest element to the source is 236 | // very likely to be an element that did not move along with the drag 237 | const sourceElements = lastMouseDown[event.button].elements; 238 | const targetElements = this.document.elementsFromPoint( 239 | event.clientX, 240 | event.clientY 241 | ); 242 | for (let i = 0; i < sourceElements.length; ++i) { 243 | if (sourceElements[i] !== targetElements[i]) { 244 | return targetElements[i]; 245 | } 246 | } 247 | 248 | // TODO: Using document.body instead of document.documentElement because of 249 | // https://code.google.com/p/chromedriver/issues/detail?id=1049 250 | return this.document.body; 251 | })(); 252 | } else { 253 | target = event.target; 254 | } 255 | 256 | const rect = target.getBoundingClientRect(); 257 | 258 | this.send({ 259 | altKey: event.altKey, 260 | button: event.button, 261 | buttons: event.buttons, 262 | ctrlKey: event.ctrlKey, 263 | clientX: event.clientX, 264 | clientY: event.clientY, 265 | elementX: event.clientX - rect.left, 266 | elementY: event.clientY - rect.top, 267 | // key has not yet been implemented in Safari, which requires the 268 | // deprecated keyIdentifier 269 | // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key#Browser_compatibility 270 | key: event.key || (event).keyIdentifier, 271 | location: event.location, 272 | metaKey: event.metaKey, 273 | shiftKey: event.shiftKey, 274 | target: this.getTarget(target), 275 | targetFrame: [], 276 | type: event.type 277 | }); 278 | } 279 | 280 | setStrategy(value: string) { 281 | switch (value) { 282 | case 'xpath': 283 | this.getTarget = this.getElementXPath; 284 | break; 285 | case 'text': 286 | this.getTarget = this.getElementTextPath; 287 | break; 288 | default: 289 | throw new Error('Invalid strategy "' + value + '"'); 290 | } 291 | } 292 | 293 | setCustomAttribute(value: string) { 294 | this._customAttr = value; 295 | } 296 | } 297 | 298 | export interface ElementLike { 299 | hasAttribute(attr: string): boolean; 300 | nodeName: string; 301 | getAttribute(attr: string): string | null; 302 | id?: string; 303 | parentNode?: ElementLike | DocumentLike | null; 304 | previousElementSibling?: ElementLike | null; 305 | nextElementSibling?: ElementLike | null; 306 | } 307 | 308 | export interface DocumentLike { 309 | parentNode: null; 310 | } 311 | -------------------------------------------------------------------------------- /src/Recorder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChromeLike, 3 | PortLike, 4 | HotKeyDef, 5 | Message, 6 | RecorderPort, 7 | Strategy, 8 | Test 9 | } from './types'; 10 | 11 | export default class Recorder { 12 | chrome: ChromeLike; 13 | storage: Storage; 14 | hotkeys: HotKeys; 15 | strategy: Strategy; 16 | findDisplayed: boolean | false; 17 | customAttribute: string | undefined | null; 18 | recording: boolean; 19 | tabId: number | undefined | null; 20 | 21 | _currentModifiers: { [modifier: string]: boolean }; 22 | _currentTest: Test | null; 23 | _findCommand: 'findDisplayedByXpath' | 'findByXpath'; 24 | _ignoreKeyups: { [key: string]: boolean }; 25 | _lastMouseMove: RecorderMouseEvent | null; 26 | _lastTarget: Element | null; 27 | _lastTargetFrame: number[]; 28 | _lastTestId: 0; 29 | _recordNextMouseMove: boolean; 30 | _suiteName: string | undefined; 31 | 32 | // connects to an EventProxy instance representing the web page that the devtools are open for 33 | _contentPort: RecorderPort | null; 34 | 35 | // connects to a RecorderProxy instance representing the "Intern" panel in Chrome devtools 36 | _port: RecorderPort | null; 37 | 38 | _script: string; 39 | _scriptTree: Test[]; 40 | 41 | constructor(chrome?: ChromeLike, storage?: Storage) { 42 | if (chrome == null) { 43 | throw new Error('Chrome API must be provided to recorder'); 44 | } 45 | 46 | if (storage == null) { 47 | throw new Error('Storage API must be provided to recorder'); 48 | } 49 | 50 | this.chrome = chrome; 51 | this.storage = storage; 52 | 53 | const storedKeys = storage.getItem('intern.hotkeys'); 54 | this.hotkeys = storedKeys 55 | ? JSON.parse(storedKeys) 56 | : this._getDefaultHotkeys(); 57 | 58 | this.strategy = storage.getItem('intern.strategy') || 'xpath'; 59 | this.findDisplayed = 60 | parseBoolean(storage.getItem('intern.findDisplayed')) || false; 61 | this.customAttribute = storage.getItem('intern.customAttribute'); 62 | 63 | this.recording = false; 64 | this.tabId = null; 65 | this._findCommand = 'findByXpath'; 66 | 67 | this._initializeScript(); 68 | this._initializePort(); 69 | this._initializeNavigation(); 70 | this.clear(); 71 | } 72 | 73 | clear() { 74 | this._currentModifiers = {}; 75 | this._currentTest = null; 76 | this._lastMouseMove = null; 77 | this._lastTarget = null; 78 | this._lastTargetFrame = []; 79 | this._lastTestId = 0; 80 | this._ignoreKeyups = {}; 81 | this._script = ''; 82 | this._scriptTree = []; 83 | this._suiteName = 'recorder-generated suite'; 84 | 85 | if (this.tabId) { 86 | this.newTest(); 87 | } 88 | } 89 | 90 | _eraseKeys(numKeys: number) { 91 | const commands = this._currentTest!.commands; 92 | const lastCommand = commands[commands.length - 1]; 93 | 94 | if (numKeys === lastCommand.args[0].length) { 95 | this._eraseLast(1); 96 | return; 97 | } 98 | 99 | lastCommand.args[0] = lastCommand.args[0].slice(0, -numKeys); 100 | lastCommand.text = createCommandText( 101 | lastCommand.method, 102 | lastCommand.args 103 | ); 104 | lastCommand.end = lastCommand.start + lastCommand.text.length; 105 | 106 | this._renderScriptTree(); 107 | } 108 | 109 | _eraseLast(numCommands: number) { 110 | const commands = this._currentTest!.commands; 111 | commands.splice(commands.length - numCommands, numCommands); 112 | this._renderScriptTree(); 113 | } 114 | 115 | _eraseThrough(methodName: string) { 116 | const commands = this._currentTest!.commands; 117 | 118 | let index = commands.length - 1; 119 | while (index > -1 && commands[index].method !== methodName) { 120 | --index; 121 | } 122 | 123 | /* istanbul ignore else: guard for condition that should not occur unless there is a bug */ 124 | if (index > -1) { 125 | commands.splice(index, Infinity); 126 | this._renderScriptTree(); 127 | } 128 | } 129 | 130 | _getDefaultHotkeys() { 131 | return { 132 | insertCallback: { 133 | altKey: true, 134 | shiftKey: true, 135 | key: /* c */ 'U+0043' 136 | }, 137 | insertMouseMove: { 138 | altKey: true, 139 | shiftKey: true, 140 | key: /* m */ 'U+004D' 141 | }, 142 | toggleState: { altKey: true, shiftKey: true, key: /* p */ 'U+0050' } 143 | }; 144 | } 145 | 146 | _getDefaultPort() { 147 | return { 148 | send: function() {} 149 | }; 150 | } 151 | 152 | _handleHotkeyEvent(event: RecorderKeyboardEvent) { 153 | if (event.type !== 'keydown') { 154 | return; 155 | } 156 | 157 | const ignoreKeyupMap = this._ignoreKeyups; 158 | function ignore(key: string) { 159 | ignoreKeyupMap[key] = true; 160 | } 161 | 162 | nextKey: for (const hotkeyId in this.hotkeys) { 163 | const hotkey = this.hotkeys[hotkeyId]; 164 | 165 | // All hotkey modifiers must be checked even if they are not in the incoming data to avoid false 166 | // activation, e.g. Ctrl+Home should not activate a hotkey Home 167 | for (const key in { 168 | altKey: true, 169 | ctrlKey: true, 170 | metaKey: true, 171 | shiftKey: true 172 | }) { 173 | if ( 174 | Boolean(event[key]) !== 175 | Boolean(hotkey[key]) 176 | ) { 177 | continue nextKey; 178 | } 179 | } 180 | 181 | if (event.key !== hotkey.key) { 182 | continue nextKey; 183 | } 184 | 185 | if (!this.recording && hotkeyId !== 'toggleState') { 186 | return; 187 | } 188 | 189 | ignore(hotkey.key); 190 | 191 | let numKeysToErase = 0; 192 | 193 | [ 194 | ['altKey', 'Alt'], 195 | ['ctrlKey', 'Control'], 196 | ['metaKey', 'Meta'], 197 | ['shiftKey', 'Shift'] 198 | ].forEach(key => { 199 | if ( 200 | hotkey[key[0]] && 201 | this._currentModifiers[key[1]] 202 | ) { 203 | ignore(key[1]); 204 | delete this._currentModifiers[key[1]]; 205 | ++numKeysToErase; 206 | } 207 | }); 208 | 209 | if (numKeysToErase) { 210 | this._eraseKeys(numKeysToErase); 211 | } 212 | 213 | (this[hotkeyId])(); 214 | 215 | return true; 216 | } 217 | 218 | return false; 219 | } 220 | 221 | _initializePort() { 222 | // two ports are received, one from the event proxy (injected page script) and one from the recorder proxy 223 | // (devtools panel script) 224 | this.chrome.runtime.onConnect.addListener(port => { 225 | const receiveMessage = (message: object, _port: PortLike) => { 226 | const msg = message; 227 | const method = msg.method; 228 | if (!this[method]) { 229 | throw new Error( 230 | `Method "${method}" does not exist on Recorder` 231 | ); 232 | } 233 | 234 | (this[method])(...(msg.args || [])); 235 | }; 236 | 237 | const disconnect = () => { 238 | port.onMessage.removeListener(receiveMessage); 239 | port.onDisconnect.removeListener(disconnect); 240 | 241 | if (port.name === 'recorderProxy') { 242 | this._port = this._getDefaultPort(); 243 | this.recording = false; 244 | } else if (port.name === 'eventProxy') { 245 | /* istanbul ignore else: there are only two port types */ 246 | this._contentPort = this._getDefaultPort(); 247 | } 248 | }; 249 | 250 | port.onMessage.addListener(receiveMessage); 251 | port.onDisconnect.addListener(disconnect); 252 | 253 | if (port.name === 'recorderProxy') { 254 | this._port = { 255 | send(method: string, args: any[]) { 256 | return port.postMessage({ method: method, args: args }); 257 | } 258 | }; 259 | 260 | this.refreshUi(); 261 | } else if (port.name === 'eventProxy') { 262 | /* istanbul ignore else: there are only two port types */ 263 | this._contentPort = { 264 | send: function(method, args) { 265 | return port.postMessage({ method: method, args: args }); 266 | } 267 | }; 268 | 269 | this._contentPort.send('setStrategy', [this.strategy]); 270 | if (this.customAttribute) { 271 | this._contentPort.send('setCustomAttribute', [ 272 | this.customAttribute 273 | ]); 274 | } 275 | } 276 | }); 277 | 278 | this._contentPort = this._getDefaultPort(); 279 | this._port = this._getDefaultPort(); 280 | } 281 | 282 | _initializeNavigation() { 283 | const handleNavigation = ( 284 | detail: chrome.webNavigation.WebNavigationTransitionCallbackDetails 285 | ) => { 286 | // frameId !== 0 is a subframe; we could try navigating these if we 287 | // could figure out what the entry for the subframe was for a 288 | // `switchToFrame` call 289 | if ( 290 | !this.recording || 291 | detail.tabId !== this.tabId || 292 | detail.frameId !== 0 293 | ) { 294 | return; 295 | } 296 | 297 | if (detail.transitionType === 'reload') { 298 | this._recordTarget(null); 299 | this._record('refresh'); 300 | } else if ( 301 | detail.transitionQualifiers.indexOf('forward_back') !== -1 302 | ) { 303 | // Chrome does not specify whether it was forward button or back button so for now we simply 304 | // re-enter the url; this will not correctly test bfcache but will at least ensure we end up back 305 | // on the correct page. We could try to guess which way it went by recording tab history but this 306 | // would only work if the two surrounding pages are not the same 307 | this._recordTarget(null); 308 | this._record('get', [detail.url]); 309 | } else if ( 310 | detail.transitionQualifiers.indexOf('from_address_bar') !== -1 311 | ) { 312 | this._recordTarget(null); 313 | this._record('get', [detail.url]); 314 | } 315 | 316 | this._injectContentScript(); 317 | this._renderScriptTree(); 318 | }; 319 | 320 | this.chrome.webNavigation.onCommitted.addListener(handleNavigation); 321 | this.chrome.webNavigation.onReferenceFragmentUpdated.addListener( 322 | handleNavigation 323 | ); 324 | this.chrome.webNavigation.onHistoryStateUpdated.addListener( 325 | handleNavigation 326 | ); 327 | } 328 | 329 | _initializeScript() { 330 | this._script = ''; 331 | this._scriptTree = []; 332 | } 333 | 334 | _injectContentScript() { 335 | this.chrome.tabs.executeScript(this.tabId!, { 336 | file: 'lib/content.js', 337 | allFrames: true 338 | }); 339 | } 340 | 341 | insertCallback() { 342 | if (!this.recording) { 343 | return; 344 | } 345 | 346 | this._record('then', [FUNCTION_OBJECT]); 347 | this._renderScriptTree(); 348 | } 349 | 350 | insertMouseMove() { 351 | if (!this.recording || !this._lastMouseMove) { 352 | return; 353 | } 354 | 355 | const event = this._lastMouseMove!; 356 | 357 | this._recordTarget(event); 358 | this._record('moveMouseTo', [event.elementX, event.elementY], 1); 359 | this._renderScriptTree(); 360 | } 361 | 362 | newTest() { 363 | if (!this.tabId) { 364 | throw new Error('Cannot add new test due to missing tabId'); 365 | } 366 | 367 | if (this._currentTest) { 368 | if (this._lastTarget) { 369 | this._record('end', null, 1); 370 | } 371 | if (this._lastTargetFrame.length) { 372 | this._record('switchToFrame', [anyNull]); 373 | } 374 | this._lastTarget = null; 375 | this._lastTargetFrame = []; 376 | } 377 | 378 | const test: Test = { 379 | name: `Test ${++this._lastTestId}`, 380 | commands: [], 381 | start: 0, 382 | end: 0 383 | }; 384 | 385 | this._currentTest = test; 386 | this._scriptTree.push(test); 387 | 388 | this.chrome.tabs.get(this.tabId, tab => { 389 | this._record('get', [tab.url]); 390 | this._renderScriptTree(); 391 | }); 392 | } 393 | 394 | recordEvent(event: RecorderEvent) { 395 | if (this._handleHotkeyEvent(event)) { 396 | return; 397 | } 398 | 399 | if (!this.recording) { 400 | return; 401 | } 402 | 403 | const mouseEvent = event; 404 | const keyboardEvent = event; 405 | 406 | switch (event.type) { 407 | case 'click': 408 | this._eraseThrough('pressMouseButton'); 409 | // moveMouseTo 410 | this._eraseLast(1); 411 | 412 | // moveMouseTo is recorded on click and dblclick instead of using the already-recorded moveMouseTo 413 | // from the previous mousedown/mouseup because there may be a slight difference in position due to 414 | // hysteresis 415 | this._record( 416 | 'moveMouseTo', 417 | [mouseEvent.elementX, mouseEvent.elementY], 418 | 1 419 | ); 420 | this._record('clickMouseButton', [mouseEvent.button], 1); 421 | break; 422 | 423 | case 'dblclick': 424 | // click (2), click (2) 425 | this._eraseThrough('clickMouseButton'); 426 | this._eraseThrough('clickMouseButton'); 427 | // mouseMoveTo 428 | this._eraseLast(1); 429 | 430 | // moveMouseTo is recorded on click and dblclick instead of using the already-recorded moveMouseTo 431 | // from the previous mousedown/mouseup because there may be a slight difference in position due to 432 | // hysteresis 433 | this._record( 434 | 'moveMouseTo', 435 | [mouseEvent.elementX, mouseEvent.elementY], 436 | 1 437 | ); 438 | this._record('doubleClick', null, 1); 439 | break; 440 | 441 | case 'keydown': 442 | if (isModifierKey(keyboardEvent.key)) { 443 | this._currentModifiers[keyboardEvent.key] = true; 444 | } 445 | 446 | this._recordKey(keyboardEvent); 447 | break; 448 | 449 | case 'keyup': 450 | if (this._ignoreKeyups[keyboardEvent.key]) { 451 | delete this._ignoreKeyups[keyboardEvent.key]; 452 | delete this._currentModifiers[keyboardEvent.key]; 453 | return; 454 | } 455 | 456 | if (isModifierKey(keyboardEvent.key)) { 457 | delete this._currentModifiers[keyboardEvent.key]; 458 | this._recordKey(keyboardEvent); 459 | } 460 | break; 461 | 462 | case 'mousedown': 463 | this._recordTarget(mouseEvent); 464 | this._record( 465 | 'moveMouseTo', 466 | [mouseEvent.elementX, mouseEvent.elementY], 467 | 1 468 | ); 469 | this._record('pressMouseButton', [mouseEvent.button], 1); 470 | // The extra mouse move works around issues with DnD implementations like dojo/dnd where they 471 | // require at least one mouse move over the source element in order to activate 472 | this._recordNextMouseMove = true; 473 | break; 474 | 475 | case 'mousemove': 476 | this._lastMouseMove = mouseEvent; 477 | if (this._recordNextMouseMove) { 478 | this._recordNextMouseMove = false; 479 | this.insertMouseMove(); 480 | } 481 | break; 482 | 483 | case 'mouseup': 484 | this._recordNextMouseMove = false; 485 | this._recordTarget(mouseEvent); 486 | this._record( 487 | 'moveMouseTo', 488 | [mouseEvent.elementX, mouseEvent.elementY], 489 | 1 490 | ); 491 | this._record('releaseMouseButton', [mouseEvent.button], 1); 492 | break; 493 | } 494 | 495 | this._renderScriptTree(); 496 | } 497 | 498 | _record(method: string, args?: any[] | null, indent?: number) { 499 | const test = this._currentTest!; 500 | const commands = test.commands; 501 | 502 | const text = createCommandText(method, args, indent); 503 | const start = 504 | commands.length > 0 505 | ? commands[commands.length - 1].end 506 | : test.start; 507 | 508 | if (commands.length === 0 && method === 'get' && args) { 509 | const url = args[0]; 510 | const page = url.replace(/\/$/, '').slice(url.lastIndexOf('/') + 1); 511 | this.setSuiteName(page); 512 | } 513 | 514 | commands.push({ 515 | text: text, 516 | method: method, 517 | args: args || [], 518 | start: start, 519 | end: start + text.length 520 | }); 521 | 522 | this._renderScriptTree(); 523 | } 524 | 525 | _recordKey(event: RecorderKeyboardEvent) { 526 | const suppressesShift = (key: string) => { 527 | const code = key.charCodeAt(0); 528 | if (code >= 0xe000 && code <= 0xf8ff) { 529 | return false; 530 | } 531 | 532 | return key.toUpperCase() === key; 533 | }; 534 | 535 | const key = getSeleniumKey(event.key, event.location, event.shiftKey); 536 | 537 | const commands = this._currentTest!.commands; 538 | const lastCommand = commands[commands.length - 1]; 539 | const indent = this._lastTarget ? 1 : 0; 540 | 541 | if (lastCommand && lastCommand.method === 'pressKeys') { 542 | const shiftKey = getSeleniumKey('Shift', 0, true); 543 | const args = lastCommand.args; 544 | const lastKey = args[0].charAt(args[0].length - 1); 545 | 546 | // if the previous character was a Shift to start typing this 547 | // uppercase letter, remove the Shift from the output since it is 548 | // encoded in our use of an uppercase letter 549 | if (suppressesShift(key) && lastKey === shiftKey) { 550 | args[0] = args[0].slice(0, -1); 551 | } else if ( 552 | event.type === 'keyup' && 553 | key === shiftKey && 554 | suppressesShift(lastKey) 555 | ) { 556 | // if the previous character was an uppercase letter and this 557 | // key is a Shift release, do not add the Shift release; it 558 | // will be encoded in the next letter 559 | return; 560 | } 561 | 562 | args[0] += key; 563 | 564 | lastCommand.text = createCommandText( 565 | lastCommand.method, 566 | args, 567 | indent 568 | ); 569 | lastCommand.end = lastCommand.start + lastCommand.text.length; 570 | } else { 571 | this._record('pressKeys', [key], indent); 572 | } 573 | } 574 | 575 | _recordTarget(event: RecorderMouseEvent | null) { 576 | const checkTargetFrameChanged = () => { 577 | if (evt.targetFrame.length !== lastTargetFrame.length) { 578 | return true; 579 | } 580 | 581 | for (let i = 0, j = lastTargetFrame.length; i < j; ++i) { 582 | if (evt.targetFrame[i] !== lastTargetFrame[i]) { 583 | return true; 584 | } 585 | } 586 | 587 | return false; 588 | }; 589 | 590 | const evt = event || { target: null, targetFrame: [] }; 591 | const lastTargetFrame = this._lastTargetFrame; 592 | const targetFrameChanged = checkTargetFrameChanged(); 593 | const targetChanged = evt.target !== this._lastTarget; 594 | 595 | if (targetFrameChanged || targetChanged) { 596 | if (this._lastTarget) { 597 | this._record('end', null, 1); 598 | } 599 | 600 | if (targetFrameChanged) { 601 | if (lastTargetFrame.length) { 602 | this._record('switchToFrame', [anyNull]); 603 | } 604 | evt.targetFrame.forEach(frameId => { 605 | this._record('switchToFrame', [frameId]); 606 | }); 607 | this._lastTargetFrame = evt.targetFrame; 608 | } 609 | 610 | if (evt.target) { 611 | this._record(this._findCommand, [evt.target]); 612 | } 613 | 614 | this._lastTarget = evt.target; 615 | } 616 | } 617 | 618 | refreshUi() { 619 | const port = this._port!; 620 | port.send('setFindDisplayed', [this.findDisplayed]); 621 | port.send('setScript', [this._script]); 622 | port.send('setRecording', [this.recording]); 623 | port.send('setStrategy', [this.strategy]); 624 | port.send('setCustomAttribute', [this.customAttribute]); 625 | 626 | for (const hotkeyId in this.hotkeys) { 627 | port.send('setHotkey', [ 628 | hotkeyId, 629 | this.hotkeys[hotkeyId] 630 | ]); 631 | } 632 | } 633 | 634 | _renderScriptTree() { 635 | const script = [ 636 | templates.suiteOpen.replace('$NAME', this._suiteName!), 637 | this._scriptTree 638 | .map(test => 639 | [ 640 | templates.testOpen.replace('$NAME', test.name), 641 | ...test.commands.map(command => command.text), 642 | templates.testClose 643 | ].join('') 644 | ) 645 | .join('\n'), 646 | templates.suiteClose 647 | ]; 648 | 649 | this.setScript(script.join('')); 650 | } 651 | 652 | save() { 653 | const file = new Blob([this._script], { 654 | type: 'application/ecmascript' 655 | }); 656 | const url = URL.createObjectURL(file); 657 | 658 | this.chrome.downloads.download( 659 | { 660 | filename: `${url.slice(url.lastIndexOf('/') + 1)}.js`, 661 | url, 662 | saveAs: true 663 | }, 664 | function() { 665 | URL.revokeObjectURL(url); 666 | } 667 | ); 668 | } 669 | 670 | setFindDisplayed(value: boolean) { 671 | this.findDisplayed = value; 672 | const valueStr = value ? 'true' : 'false'; 673 | this.storage.setItem('intern.findDisplayed', valueStr); 674 | this._findCommand = value ? 'findDisplayedByXpath' : 'findByXpath'; 675 | } 676 | 677 | setHotkey(hotkeyId: keyof HotKeys, hotkey: HotKeyDef) { 678 | this.hotkeys[hotkeyId] = hotkey; 679 | this._port!.send('setHotkey', [hotkeyId, hotkey]); 680 | this.storage.setItem('intern.hotkeys', JSON.stringify(this.hotkeys)); 681 | } 682 | 683 | setScript(value: string) { 684 | this._script = value; 685 | this._port!.send('setScript', [value]); 686 | } 687 | 688 | setSuiteName(value: string) { 689 | this._suiteName = value || 'recorder-generated suite'; 690 | this._renderScriptTree(); 691 | } 692 | 693 | setCustomAttribute(value: string) { 694 | this.customAttribute = value; 695 | this.storage.setItem('intern.customAttribute', value); 696 | this._contentPort!.send('setCustomAttribute', [value]); 697 | } 698 | 699 | setStrategy(value: Strategy) { 700 | if (value !== 'xpath' && value !== 'text') { 701 | throw new Error(`Invalid search strategy "${value}"`); 702 | } 703 | 704 | this.storage.setItem('intern.strategy', value); 705 | this.strategy = value; 706 | this._contentPort!.send('setStrategy', [value]); 707 | } 708 | 709 | setTabId(tabId: number) { 710 | if (tabId && this.tabId !== tabId) { 711 | this.tabId = tabId; 712 | this.clear(); 713 | } 714 | } 715 | 716 | toggleState() { 717 | if (!this.tabId) { 718 | throw new Error('Cannot update state due to missing tabId'); 719 | } 720 | 721 | if (!this.recording) { 722 | this._injectContentScript(); 723 | } 724 | 725 | this.recording = !this.recording; 726 | this._port!.send('setRecording', [this.recording]); 727 | } 728 | } 729 | 730 | export interface RecorderEvent { 731 | type: string; 732 | targetFrame: number[]; 733 | } 734 | 735 | export interface RecorderMouseEvent extends RecorderEvent { 736 | elementX: number; 737 | elementY: number; 738 | button: number; 739 | buttons: number; 740 | target: string | Element; 741 | } 742 | 743 | export interface RecorderKeyboardEvent extends RecorderEvent { 744 | key: string; 745 | location: number; 746 | ctrlKey: boolean; 747 | shiftKey: boolean; 748 | } 749 | 750 | export interface HotKeys { 751 | insertCallback: HotKeyDef; 752 | insertMouseMove: HotKeyDef; 753 | toggleState: HotKeyDef; 754 | } 755 | 756 | const FUNCTION_OBJECT = { 757 | toString() { 758 | return '() => {}'; 759 | } 760 | }; 761 | 762 | function createCommandText( 763 | method: string, 764 | args?: any[] | null, 765 | indent?: number 766 | ) { 767 | let text = `\n${getIndent(3)}${getIndent(indent)}.${method}(`; 768 | 769 | if (args && args.length) { 770 | args.forEach((arg, index) => { 771 | if (index > 0) { 772 | text += ', '; 773 | } 774 | 775 | if (typeof arg === 'string') { 776 | text += `'${arg.replace(/'/g, "\\'")}'`; 777 | } else if (arg === anyNull) { 778 | text += 'null'; 779 | } else { 780 | text += String(arg); 781 | } 782 | }); 783 | } 784 | 785 | text += ')'; 786 | return text; 787 | } 788 | 789 | function getIndent(num = 0) { 790 | let indent = ''; 791 | while (num-- > 0) { 792 | indent += ' '; 793 | } 794 | return indent; 795 | } 796 | 797 | const KEY_MAP = { 798 | // Backspace 799 | 'U+0008': '\ue003', 800 | // Tab 801 | 'U+0009': '\ue004', 802 | // Space 803 | 'U+0020': ' ', 804 | // Escape 805 | 'U+001B': '\ue00c', 806 | // Delete 807 | 'U+007F': '\ue017', 808 | Cancel: '\uE001', 809 | Help: '\uE002', 810 | Backspace: '\uE003', 811 | Tab: '\uE004', 812 | Clear: '\uE005', 813 | Return: '\uE006', 814 | Enter: '\uE007', 815 | Shift: '\uE008', 816 | Control: '\uE009', 817 | Alt: '\uE00A', 818 | Pause: '\uE00B', 819 | Escape: '\uE00C', 820 | Space: ' ', 821 | PageUp: '\uE00E', 822 | PageDown: '\uE00F', 823 | End: '\uE010', 824 | Home: '\uE011', 825 | ArrowLeft: '\uE012', 826 | ArrowUp: '\uE013', 827 | ArrowRight: '\uE014', 828 | ArrowDown: '\uE015', 829 | Insert: '\uE016', 830 | Delete: '\uE017', 831 | F1: '\uE031', 832 | F2: '\uE032', 833 | F3: '\uE033', 834 | F4: '\uE034', 835 | F5: '\uE035', 836 | F6: '\uE036', 837 | F7: '\uE037', 838 | F8: '\uE038', 839 | F9: '\uE039', 840 | F10: '\uE03A', 841 | F11: '\uE03B', 842 | F12: '\uE03C', 843 | Meta: '\uE03D', 844 | Command: '\uE03D', 845 | ZenkakuHankaku: '\uE040' 846 | }; 847 | 848 | // Chrome on Windows has character layout bugs, see 849 | // https://code.google.com/p/chromium/issues/detail?id=48111 850 | // To resolve this for now there are some extra key maps below in the range of U+0041 to U+0090 that only 851 | // apply to Windows users 852 | const NUMPAD_KEY_MAP = { 853 | // ; 854 | 'U+003B': '\uE018', 855 | // = 856 | 'U+003D': '\uE019', 857 | // 0 858 | 'U+0030': '\uE01A', 859 | 'U+0060': '\uE01A', 860 | // 1 861 | 'U+0031': '\uE01B', 862 | 'U+0041': '\uE01B', 863 | // 2 864 | 'U+0032': '\uE01C', 865 | 'U+0042': '\uE01C', 866 | // 3 867 | 'U+0033': '\uE01D', 868 | 'U+0043': '\uE01D', 869 | // 4 870 | 'U+0034': '\uE01E', 871 | 'U+0044': '\uE01E', 872 | // 5 873 | 'U+0035': '\uE01F', 874 | 'U+0045': '\uE01F', 875 | // 6 876 | 'U+0036': '\uE020', 877 | 'U+0046': '\uE020', 878 | // 7 879 | 'U+0037': '\uE021', 880 | 'U+0047': '\uE021', 881 | // 8 882 | 'U+0038': '\uE022', 883 | 'U+0048': '\uE022', 884 | // 9 885 | 'U+0039': '\uE023', 886 | 'U+0049': '\uE023', 887 | // * 888 | 'U+002A': '\uE024', 889 | 'U+004A': '\uE024', 890 | // + 891 | 'U+002B': '\uE025', 892 | 'U+004B': '\uE025', 893 | // , 894 | 'U+002C': '\uE026', 895 | 'U+004C': '\uE026', 896 | // - 897 | 'U+002D': '\uE027', 898 | 'U+004D': '\uE027', 899 | // . 900 | 'U+002E': '\uE028', 901 | 'U+004E': '\uE028', 902 | // / 903 | 'U+002F': '\uE029', 904 | 'U+004F': '\uE029' 905 | }; 906 | 907 | function getSeleniumKey( 908 | key: string, 909 | keyLocation: number, 910 | isUpperCase: boolean 911 | ) { 912 | const numPadKey = key; 913 | if (keyLocation === /* numpad */ 3 && NUMPAD_KEY_MAP[numPadKey]) { 914 | return NUMPAD_KEY_MAP[numPadKey]; 915 | } 916 | 917 | const keyKey = key; 918 | if (KEY_MAP[keyKey]) { 919 | return KEY_MAP[keyKey]; 920 | } 921 | 922 | /* istanbul ignore else: should be impossible */ 923 | if (key.slice(0, 2) === 'U+') { 924 | const char = String.fromCharCode(Number(`0x${key.slice(2)}`)); 925 | return isUpperCase ? char.toUpperCase() : char.toLowerCase(); 926 | } else { 927 | return key; 928 | } 929 | } 930 | 931 | function parseBoolean(value: string | null) { 932 | return value === 'true'; 933 | } 934 | 935 | const modifiers: { [key: string]: boolean } = { 936 | Shift: true, 937 | Control: true, 938 | Alt: true, 939 | Meta: true 940 | }; 941 | 942 | function isModifierKey(key: string) { 943 | return Boolean(modifiers[key]); 944 | } 945 | 946 | const templates = { 947 | suiteOpen: [ 948 | "const { suite, test } = intern.getPlugin('interface.tdd');", 949 | '', 950 | 951 | "// Uncomment the line below to use chai's 'assert' interface.", 952 | "// const { assert } = intern.getPlugin('chai');", 953 | 954 | '', 955 | 956 | "// Export the suite to ensure that it's built as a module rather", 957 | '// than a simple script.', 958 | "export default suite('$NAME', () => {" 959 | ].join('\n'), 960 | testOpen: ['', " test('$NAME', tst => {", ' return tst.remote'].join( 961 | '\n' 962 | ), 963 | testClose: [';', ' });'].join('\n'), 964 | suiteClose: ['', '});', ''].join('\n') 965 | }; 966 | 967 | const anyNull = {}; 968 | -------------------------------------------------------------------------------- /src/RecorderProxy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HotKeyDef, 3 | Message, 4 | Strategy, 5 | ChromeLike, 6 | PanelLike, 7 | PortLike, 8 | ButtonLike 9 | } from './types'; 10 | import * as hljs from 'highlight.js'; 11 | 12 | export default class RecorderProxy { 13 | chrome: ChromeLike; 14 | contentWindow: Window | null; 15 | panel: PanelLike; 16 | recording: boolean | undefined; 17 | 18 | _port: PortLike | null; 19 | _recordButton: ButtonLike | undefined; 20 | _script: HTMLPreElement | undefined; 21 | _toggleOnShow: boolean | undefined; 22 | 23 | constructor(chrome: ChromeLike, panel: PanelLike) { 24 | this.chrome = chrome; 25 | this.panel = panel; 26 | 27 | this.contentWindow = null; 28 | this.recording = false; 29 | this._port = null; 30 | 31 | this._initializeUi(); 32 | this._initializePort(); 33 | } 34 | 35 | _getHotkeyLabel(hotkey: HotKeyDef) { 36 | let charMap: { [key: string]: string }; 37 | let metaMap: { [key: string]: string }; 38 | let getKey: (hotkey: HotKeyDef) => string; 39 | 40 | function getChar(key: string) { 41 | return key.slice(0, 2) === 'U+' 42 | ? String.fromCharCode(Number('0x' + key.slice(2))).toUpperCase() 43 | : null; 44 | } 45 | 46 | if (this.contentWindow!.navigator.platform === 'MacIntel') { 47 | metaMap = { 48 | Alt: '⌥', 49 | Control: '^', 50 | Meta: '⌘', 51 | Shift: '⇧' 52 | }; 53 | charMap = { 54 | 'U+0008': 'Backspace', 55 | 'U+0009': '↹', 56 | 'U+0020': 'Space', 57 | 'U+001B': '⎋', 58 | 'U+007F': 'Delete', 59 | Down: '↓', 60 | Left: '←', 61 | Right: '→', 62 | Up: '↑' 63 | }; 64 | getKey = hotkey => hotkey.key; 65 | } else { 66 | metaMap = { 67 | Alt: 'Alt+', 68 | Control: 'Ctrl+', 69 | Meta: 'Win+', 70 | Shift: 'Shift+' 71 | }; 72 | charMap = { 73 | 'U+0008': 'Backspace', 74 | 'U+0009': 'Tab', 75 | 'U+0020': 'Space', 76 | 'U+001B': 'Esc', 77 | 'U+007F': 'Del' 78 | }; 79 | // Chrome on Windows has character layout bugs, see 80 | // https://code.google.com/p/chromium/issues/detail?id=48111 81 | // To resolve this for now we just add more maps for the incorrect values on standard US keyboard 82 | getKey = hotkey => { 83 | const fixedValues = BAD_KEYS[hotkey.key]; 84 | if (fixedValues) { 85 | return ( 86 | 'U+00' + 87 | fixedValues 88 | .charCodeAt(hotkey.shiftKey ? 1 : 0) 89 | .toString(16) 90 | .toUpperCase() 91 | ); 92 | } 93 | return hotkey.key; 94 | }; 95 | } 96 | 97 | function getHotkeyLabel(hotkey: HotKeyDef) { 98 | function append(key: string) { 99 | key = metaMap[key] || key; 100 | label += key; 101 | } 102 | 103 | let label = ''; 104 | const key = getKey(hotkey); 105 | const char = getChar(key); 106 | const isLetter = Boolean(char) && char !== char!.toLowerCase(); 107 | 108 | if (hotkey.ctrlKey) { 109 | append('Control'); 110 | } 111 | 112 | if (hotkey.altKey) { 113 | append('Alt'); 114 | } 115 | 116 | // shifted non-letter keys are identified by their shifted value already, so don’t display the shift 117 | // modifier if the key is a shifted non-letter (e.g. 1 -> !, 2 -> @) 118 | if (hotkey.shiftKey && (!char || char === ' ' || isLetter)) { 119 | append('Shift'); 120 | } 121 | 122 | if (hotkey.metaKey) { 123 | append('Meta'); 124 | } 125 | 126 | if (key in charMap) { 127 | append(charMap[key]); 128 | } else if (char) { 129 | append(char); 130 | } else if (!(key in metaMap)) { 131 | append(key); 132 | } 133 | 134 | return label; 135 | } 136 | 137 | this._getHotkeyLabel = getHotkeyLabel; 138 | return getHotkeyLabel(hotkey); 139 | } 140 | 141 | _hide() { 142 | // To avoid recording spurious interaction when a user has switched to another dev tools panel, pause 143 | // recording automatically when this panel is hidden and resume it when a user switches back 144 | if (this.recording) { 145 | this._toggleOnShow = true; 146 | this.send('toggleState'); 147 | } else { 148 | this._toggleOnShow = false; 149 | } 150 | } 151 | 152 | _initializeHotkeys() { 153 | const { document } = this.contentWindow!; 154 | 155 | ['insertCallback', 'insertMouseMove', 'toggleState'].forEach(id => { 156 | const input = document.getElementById('hotkey-' + id); 157 | 158 | /* istanbul ignore if: the recorder is broken if this ever happens */ 159 | if (!input) { 160 | throw new Error( 161 | 'Panel is missing input for hotkey "' + id + '"' 162 | ); 163 | } 164 | 165 | input.onkeydown = event => { 166 | event.preventDefault(); 167 | this.send('setHotkey', [id, getHotkey(event)]); 168 | }; 169 | }); 170 | } 171 | 172 | _initializeOptions() { 173 | const { document } = this.contentWindow!; 174 | const suiteNameInput = document.getElementById('option-suite-name'); 175 | const strategyInput = document.getElementById('option-strategy'); 176 | const customAttrInput = document.getElementById( 177 | 'option-custom-attribute' 178 | ); 179 | 180 | /* istanbul ignore if: the recorder is broken if this ever happens */ 181 | if (!suiteNameInput) { 182 | throw new Error('Panel is missing input for suite name'); 183 | } 184 | 185 | /* istanbul ignore if: the recorder is broken if this ever happens */ 186 | if (!strategyInput) { 187 | throw new Error('Panel is missing input for option "strategy"'); 188 | } 189 | 190 | /* istanbul ignore if: the recorder is broken if this ever happens */ 191 | if (!customAttrInput) { 192 | throw new Error( 193 | 'Panel is missing input for custom attribute option' 194 | ); 195 | } 196 | 197 | suiteNameInput.oninput = event => { 198 | this.send('setSuiteName', [(event.target).value]); 199 | }; 200 | 201 | strategyInput.onchange = event => { 202 | this.send('setStrategy', [(event.target).value]); 203 | }; 204 | 205 | customAttrInput.oninput = event => { 206 | this.send('setCustomAttribute', [ 207 | (event.target).value 208 | ]); 209 | }; 210 | 211 | const findInput = document.getElementById('option-findDisplayed'); 212 | 213 | /* istanbul ignore if: the recorder is broken if this ever happens */ 214 | if (!findInput) { 215 | throw new Error( 216 | 'Panel is missing input for option "findDisplayed"' 217 | ); 218 | } 219 | 220 | findInput.onchange = event => { 221 | this.send('setFindDisplayed', [ 222 | (event.target).checked 223 | ]); 224 | }; 225 | } 226 | 227 | _initializePort() { 228 | this._port = this.chrome.runtime.connect(this.chrome.runtime.id, { 229 | name: 'recorderProxy' 230 | }); 231 | this._port.onMessage.addListener(message => { 232 | const msg = message; 233 | const method = msg.method; 234 | if (!this[method]) { 235 | throw new Error( 236 | `Method "${method}" does not exist on RecorderProxy` 237 | ); 238 | } 239 | 240 | (this[method])(...(msg.args || [])); 241 | }); 242 | this._port.postMessage({ 243 | method: 'setTabId', 244 | args: [this.chrome.devtools.inspectedWindow.tabId] 245 | }); 246 | } 247 | 248 | _initializeScript() { 249 | const script = this.contentWindow!.document.getElementById( 250 | 'script' 251 | ); 252 | 253 | /* istanbul ignore if: the recorder is broken if this ever happens */ 254 | if (!script) { 255 | throw new Error('Panel is missing output for script'); 256 | } 257 | 258 | this._script = script; 259 | } 260 | 261 | _initializeUi() { 262 | const panel = this.panel; 263 | const controls: { 264 | action: string; 265 | button: [string, string, boolean]; 266 | }[] = [ 267 | { 268 | action: 'toggleState', 269 | button: [ 270 | 'resources/statusBarIcons/record_off.png', 271 | 'Record', 272 | false 273 | ] 274 | }, 275 | { 276 | action: 'clear', 277 | button: ['resources/statusBarIcons/clear.png', 'Clear', false] 278 | }, 279 | { 280 | action: 'newTest', 281 | button: [ 282 | 'resources/statusBarIcons/newTest.png', 283 | 'New test', 284 | false 285 | ] 286 | }, 287 | { 288 | action: 'save', 289 | button: ['resources/statusBarIcons/save.png', 'Save', false] 290 | } 291 | ]; 292 | 293 | controls.forEach(control => { 294 | const button = panel.createStatusBarButton( 295 | control.button[0], 296 | control.button[1], 297 | control.button[2] 298 | ); 299 | button.onClicked.addListener(() => { 300 | this.send(control.action); 301 | }); 302 | 303 | if (control.action === 'toggleState') { 304 | this._recordButton = button; 305 | } 306 | }); 307 | 308 | panel.onShown.addListener(this._show.bind(this)); 309 | panel.onHidden.addListener(this._hide.bind(this)); 310 | } 311 | 312 | send(method: string, args?: any[]) { 313 | this._port!.postMessage({ method: method, args: args }); 314 | } 315 | 316 | setFindDisplayed(value: boolean) { 317 | if (this.contentWindow) { 318 | (this.contentWindow!.document.getElementById( 319 | 'option-findDisplayed' 320 | )!).checked = value; 321 | } 322 | } 323 | 324 | setHotkey(id: number | string, hotkey: HotKeyDef) { 325 | if (!this.contentWindow) { 326 | return; 327 | } 328 | 329 | const input = this.contentWindow.document.getElementById( 330 | 'hotkey-' + id 331 | ); 332 | 333 | if (!input) { 334 | throw new Error('Panel is missing input for hotkey "' + id + '"'); 335 | } 336 | 337 | input.value = this._getHotkeyLabel(hotkey); 338 | } 339 | 340 | setRecording(value: boolean) { 341 | this.recording = value; 342 | this._recordButton!.update( 343 | 'resources/statusBarIcons/record_' + (value ? 'on' : 'off') + '.png' 344 | ); 345 | } 346 | 347 | setScript(value: string) { 348 | if (this._script && value != null) { 349 | this._script.innerHTML = hljs.highlight( 350 | 'typescript', 351 | value, 352 | true 353 | ).value; 354 | } 355 | } 356 | 357 | setStrategy(value: Strategy) { 358 | if (this.contentWindow) { 359 | (this.contentWindow!.document.getElementById( 360 | 'option-strategy' 361 | )).value = value; 362 | } 363 | } 364 | 365 | setCustomAttribute(value: string) { 366 | if (this.contentWindow) { 367 | (this.contentWindow!.document.getElementById( 368 | 'option-custom-attribute' 369 | )).value = value; 370 | } 371 | } 372 | 373 | _show(contentWindow: Window) { 374 | if (this.contentWindow !== contentWindow) { 375 | this.contentWindow = contentWindow; 376 | this._getHotkeyLabel = RecorderProxy.prototype._getHotkeyLabel; 377 | this._initializeScript(); 378 | this._initializeHotkeys(); 379 | this._initializeOptions(); 380 | } 381 | 382 | if (this._toggleOnShow) { 383 | this.send('toggleState'); 384 | } 385 | 386 | this.send('refreshUi'); 387 | } 388 | } 389 | 390 | function getHotkey(event: KeyboardEvent) { 391 | const hotkey: HotKeyDef = {}; 392 | 393 | ['altKey', 'ctrlKey', 'key', 'metaKey', 'shiftKey'].forEach(key => { 394 | hotkey[key] = event[key]; 395 | }); 396 | 397 | return hotkey; 398 | } 399 | 400 | const BAD_KEYS: { [key: string]: string } = { 401 | 'U+00BA': ';:', 402 | 'U+00BB': '=+', 403 | 'U+00BC': ',<', 404 | 'U+00BD': '-_', 405 | 'U+00BE': '.>', 406 | 'U+00BF': '/?', 407 | 'U+00DB': '[{', 408 | 'U+00DC': '\\|', 409 | 'U+00DD': ']}', 410 | 'U+00C0': '`~', 411 | 'U+00DE': '\'"' 412 | }; 413 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | // TODO: Put the connection handling stuff out here and pass the new port when 2 | // creating a new Recorder instance 3 | import Recorder from './Recorder'; 4 | 5 | new Recorder(chrome, localStorage); 6 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import EventProxy from './EventProxy'; 2 | 3 | let eventProxy: EventProxy | undefined; 4 | 5 | if (!eventProxy) { 6 | eventProxy = new EventProxy(window, document, chrome); 7 | } 8 | 9 | if (!eventProxy.port) { 10 | eventProxy.connect(); 11 | } 12 | -------------------------------------------------------------------------------- /src/devtools.ts: -------------------------------------------------------------------------------- 1 | import RecorderProxy from './RecorderProxy'; 2 | 3 | chrome.devtools.panels.create( 4 | 'Intern', 5 | 'recorder-on.png', 6 | 'lib/panel.html', 7 | panel => { 8 | new RecorderProxy(chrome, panel); 9 | } 10 | ); 11 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Chrome = typeof window.chrome; 2 | 3 | export type Strategy = 'xpath' | 'text'; 4 | 5 | export interface HotKeyDef { 6 | altKey?: boolean; 7 | ctrlKey?: boolean; 8 | metaKey?: boolean; 9 | shiftKey?: boolean; 10 | key: string; 11 | } 12 | 13 | export interface RecorderPort { 14 | send(method: string, args: any[]): void; 15 | } 16 | 17 | export interface Message { 18 | method: string; 19 | args: any[]; 20 | } 21 | 22 | export interface Command extends Message { 23 | start: number; 24 | end: number; 25 | text: string; 26 | } 27 | 28 | export interface Test { 29 | name: string; 30 | start: number; 31 | end: number; 32 | commands: Command[]; 33 | } 34 | 35 | // Minimal interfaces for some chrome types 36 | 37 | export interface ChromeLike { 38 | runtime: { 39 | connect: (id: string, options: chrome.runtime.ConnectInfo) => PortLike; 40 | id: string; 41 | onConnect: EventLike<(port: PortLike) => void>; 42 | }; 43 | 44 | devtools: { 45 | inspectedWindow: { 46 | tabId: number; 47 | }; 48 | }; 49 | 50 | webNavigation: { 51 | onCommitted: EventLike< 52 | ( 53 | details: chrome.webNavigation.WebNavigationTransitionCallbackDetails 54 | ) => void 55 | >; 56 | onReferenceFragmentUpdated: EventLike< 57 | ( 58 | details: chrome.webNavigation.WebNavigationTransitionCallbackDetails 59 | ) => void 60 | >; 61 | onHistoryStateUpdated: EventLike< 62 | ( 63 | details: chrome.webNavigation.WebNavigationTransitionCallbackDetails 64 | ) => void 65 | >; 66 | }; 67 | 68 | tabs: Pick; 69 | 70 | downloads: Pick; 71 | } 72 | 73 | export interface PortLike { 74 | disconnect: () => void; 75 | name: string; 76 | onDisconnect: EventLike<(port: PortLike) => void>; 77 | onMessage: EventLike<(message: Object, port: PortLike) => void>; 78 | postMessage: (message: Object) => void; 79 | } 80 | 81 | export interface EventLike { 82 | addListener(callback: T): void; 83 | removeListener(callback: T): void; 84 | } 85 | 86 | export interface PanelLike { 87 | createStatusBarButton( 88 | iconPath: string, 89 | tooltipText: string, 90 | disabled: boolean 91 | ): ButtonLike; 92 | onShown: EventLike<(window: chrome.windows.Window) => void>; 93 | onHidden: EventLike<() => void>; 94 | } 95 | 96 | export interface ButtonLike { 97 | onClicked: EventLike<() => void>; 98 | update( 99 | iconPath?: string | null, 100 | tooltipText?: string | null, 101 | disabled?: boolean | null 102 | ): void; 103 | } 104 | -------------------------------------------------------------------------------- /support/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | MATCH_VERSION="[0-9]\+\(\.[0-9]\+\)\{2,\}" 6 | 7 | usage() { 8 | echo "Usage: $0 [branch] [version]" 9 | echo 10 | echo "Branch defaults to 'master'." 11 | echo "Version defaults to what is listed in package.json in the branch." 12 | echo "Version should only be specified for pre-releases." 13 | exit 1 14 | } 15 | 16 | if [ "$1" == "--help" ]; then 17 | usage 18 | exit 0 19 | elif [ "$1" == "" ]; then 20 | BRANCH="master" 21 | else 22 | BRANCH=$1 23 | fi 24 | 25 | if [ "$2" != "" ]; then 26 | VERSION=$2 27 | fi 28 | 29 | ROOT_DIR=$(cd $(dirname $0) && cd .. && pwd) 30 | BUILD_DIR="$ROOT_DIR/build" 31 | 32 | if [ -d "$BUILD_DIR" ]; then 33 | echo "Existing build directory detected at $BUILD_DIR" 34 | echo "Aborted." 35 | exit 1 36 | fi 37 | 38 | echo "This is an internal Intern release script!" 39 | echo -n "Press 'y' to create a new Intern Recorder release from branch $BRANCH" 40 | if [ "$VERSION" == "" ]; then 41 | echo "." 42 | else 43 | echo -e "\nwith version override $VERSION." 44 | fi 45 | echo "(You can abort pushing upstream later on if something goes wrong.)" 46 | read -s -n 1 47 | 48 | if [ "$REPLY" != "y" ]; then 49 | echo "Aborted." 50 | exit 0 51 | fi 52 | 53 | cd "$ROOT_DIR" 54 | mkdir "$BUILD_DIR" 55 | git clone --recursive git@github.com:theintern/recorder.git "$BUILD_DIR" 56 | 57 | cd "$BUILD_DIR" 58 | 59 | # Store the newly created tags and all updated branches outside of the loop so we can push/publish them all at once 60 | # at the end instead of having to guess that the second loop will run successfully after the first one 61 | RELEASE_TAG= 62 | PUSH_BRANCHES="$BRANCH" 63 | 64 | echo -e "\nBuilding $BRANCH branch...\n" 65 | 66 | git checkout $BRANCH 67 | 68 | # Get the version number for this release from package.json 69 | if [ "$VERSION" == "" ]; then 70 | VERSION=$(grep -o '"version": "[^"]*"' package.json | grep -o "$MATCH_VERSION") 71 | 72 | # Convert the version number to an array that we can use to generate the next release version number 73 | OLDIFS=$IFS 74 | IFS="." 75 | PRE_VERSION=($VERSION) 76 | IFS=$OLDIFS 77 | 78 | # This is a new major/minor release 79 | if [[ $VERSION =~ \.0$ ]]; then 80 | # We'll be creating a new minor release branch for this version for any future patch releases 81 | MAKE_BRANCH="${PRE_VERSION[0]}.${PRE_VERSION[1]}" 82 | BRANCH_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1))-pre" 83 | MANIFEST_BRANCH_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1)).0" 84 | 85 | # The next release is usually going to be a minor release; if the next version is to be a major release, 86 | # the package version will need to be manually updated in Git before release 87 | MANIFEST_PRE_VERSION="${PRE_VERSION[0]}.$((PRE_VERSION[1] + 1)).0.0" 88 | PRE_VERSION="${PRE_VERSION[0]}.$((PRE_VERSION[1] + 1)).0-pre" 89 | 90 | # This is a new patch release 91 | else 92 | # Patch releases do not get a branch 93 | MAKE_BRANCH= 94 | BRANCH_VERSION= 95 | MANIFEST_BRANCH_VERSION= 96 | 97 | # The next release version will always be another patch version 98 | MANIFEST_PRE_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1)).0" 99 | PRE_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1))-pre" 100 | fi 101 | else 102 | MAKE_BRANCH= 103 | BRANCH_VERSION= 104 | MANIFEST_BRANCH_VERSION= 105 | PRE_VERSION=$(grep -o '"version": "[^"]*"' package.json | grep -o "$MATCH_VERSION") 106 | PRE_VERSION="$PRE_VERSION-pre" 107 | 108 | MANIFEST_VERSION=$(grep -o '"version": "[^"]*"' manifest.json | grep -o "$MATCH_VERSION") 109 | 110 | # Convert the version number to an array that we can use to generate the next release version number 111 | OLDIFS=$IFS 112 | IFS="." 113 | MANIFEST_PRE_VERSION=($MANIFEST_VERSION) 114 | IFS=$OLDIFS 115 | 116 | # Manifest needs the 4th version number incremented on each pre-release. The currently committed version in 117 | # manifest.json is used for this release, then it is incremented immediately for the next pre-release (or final 118 | # release). e.g.: 119 | # 1.0.0-alpha.1 -> 1.0.0.0, pre is 1.0.0.1 120 | # 1.0.0-alpha.2 -> 1.0.0.1, pre is 1.0.0.2 121 | # 1.0.0 -> 1.0.0.2, pre is 1.0.1.0 122 | MANIFEST_PRE_VERSION="${MANIFEST_PRE_VERSION[0]}.${MANIFEST_PRE_VERSION[1]}.${MANIFEST_PRE_VERSION[2]}.$((MANIFEST_PRE_VERSION[3] + 1))" 123 | fi 124 | 125 | TAG_VERSION=$VERSION 126 | RELEASE_TAG="$TAG_VERSION" 127 | 128 | # At this point: 129 | # $VERSION is the version of Intern that is being released; 130 | # $TAG_VERSION is the name that will be used for the Git tag for the release 131 | # $PRE_VERSION is the next pre-release version of Intern that will be set on the original branch after tagging 132 | # $MAKE_BRANCH is the name of the new minor release branch that should be created (if this is not a patch release) 133 | # $BRANCH_VERSION is the pre-release version of Intern that will be set on the minor release branch 134 | # $MANIFEST_* are the same versions as the unprefixed ones, except using a monotonically increasing fourth version 135 | # number instead of a semver prerelease suffix 136 | 137 | # Something is messed up and this release has already happened 138 | if [ $(git tag |grep -c "^$TAG_VERSION$") -gt 0 ]; then 139 | echo -e "\nTag $TAG_VERSION already exists! Please check the branch.\n" 140 | exit 1 141 | fi 142 | 143 | # Set the package version to release version 144 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json 145 | sed -i -e "s/\"version_name\": \"[^\"]*\"/\"version_name\": \"$VERSION\"/" manifest.json 146 | 147 | # Fix the Git-based dependencies to specific commit IDs 148 | echo -e "\nFixing dependency commits...\n" 149 | for DEP in dojo; do 150 | DEP_URL=$(grep -o "\"$DEP\": \"[^\"]*\"" package.json |grep -o 'https://[^"]*' |sed -e 's/\/archive.*//') 151 | COMMIT=$(grep -o "\"$DEP\": \"[^\"]*\"" package.json |grep -o 'https://[^"]*' |sed -e 's/.*archive\/\(.*\)\.tar\.gz/\1/') 152 | if [ "$DEP_URL" != "" ]; then 153 | if [[ "$COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then 154 | echo -e "\nDependency $DEP is already fixed to $COMMIT\n" 155 | else 156 | mkdir "$BUILD_DIR/.dep" 157 | git clone --single-branch --depth 1 --branch=$COMMIT "$DEP_URL.git" "$BUILD_DIR/.dep" 158 | cd "$BUILD_DIR/.dep" 159 | COMMIT=$(git log -n 1 --format='%H') 160 | cd "$BUILD_DIR" 161 | rm -rf "$BUILD_DIR/.dep" 162 | DEP_URL=$(echo $DEP_URL |sed -e 's/[\/&]/\\&/g') 163 | echo -e "\nFixing dependency $DEP to commit $COMMIT...\n" 164 | sed -i -e "s/\(\"$DEP\":\) \"[^\"]*\"/\1 \"$DEP_URL\/archive\/$COMMIT.tar.gz\"/" package.json 165 | fi 166 | fi 167 | done 168 | 169 | # Commit the new release to Git 170 | git commit -m "Updating metadata for $VERSION" package.json manifest.json 171 | git tag -a -m "Release $VERSION" $TAG_VERSION 172 | 173 | # Check out the previous package.json to get rid of the fixed dependencies 174 | git checkout HEAD^ package.json 175 | git reset package.json 176 | 177 | # Set the package version to next pre-release version 178 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$PRE_VERSION\"/" package.json 179 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$MANIFEST_PRE_VERSION\"/" manifest.json 180 | sed -i -e "s/\"version_name\": \"[^\"]*\"/\"version_name\": \"$PRE_VERSION\"/" manifest.json 181 | 182 | # Commit the pre-release to Git 183 | git commit -m "Updating source version to $PRE_VERSION" package.json manifest.json 184 | 185 | # If this is a major/minor release, we also create a new branch for it 186 | if [ "$MAKE_BRANCH" != "" ]; then 187 | # Create the new branch starting at the tagged release version 188 | git checkout -b $MAKE_BRANCH $TAG_VERSION 189 | 190 | # Set the package version to the next patch pre-release version 191 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$BRANCH_VERSION\"/" package.json 192 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$MANIFEST_BRANCH_VERSION\"/" manifest.json 193 | sed -i -e "s/\"version_name\": \"[^\"]*\"/\"version_name\": \"$BRANCH_VERSION\"/" manifest.json 194 | 195 | # Commit the pre-release to Git 196 | git commit -m "Updating source version to $BRANCH_VERSION" package.json manifest.json 197 | 198 | # Store the branch as one that needs to be pushed when we are ready to deploy the release 199 | PUSH_BRANCHES="$PUSH_BRANCHES $MAKE_BRANCH" 200 | fi 201 | 202 | echo -e "\nDone!\n" 203 | 204 | echo "Please confirm packaging success, then press 'y', ENTER to build release archive," 205 | echo "push tags $RELEASE_TAG, and upload, or any other key to bail." 206 | read -p "> " 207 | 208 | if [ "$REPLY" != "y" ]; then 209 | echo "Aborted." 210 | exit 0 211 | fi 212 | 213 | for BRANCH in $PUSH_BRANCHES; do 214 | git push origin $BRANCH 215 | done 216 | 217 | git push origin --tags 218 | 219 | git checkout $RELEASE_TAG 220 | zip -9r $ROOT_DIR/recorder-$RELEASE_TAG.zip . -x@.zipignore 221 | 222 | cd "$ROOT_DIR" 223 | rm -rf "$BUILD_DIR" 224 | 225 | echo -e "\nAll done! Yay!" 226 | -------------------------------------------------------------------------------- /support/release.ts: -------------------------------------------------------------------------------- 1 | MATCH_VERSION="[0-9]\+\(\.[0-9]\+\)\{2,\}" 2 | 3 | usage() { 4 | echo "Usage: $0 [branch] [version]" 5 | echo 6 | echo "Branch defaults to 'master'." 7 | echo "Version defaults to what is listed in package.json in the branch." 8 | echo "Version should only be specified for pre-releases." 9 | exit 1 10 | } 11 | 12 | if [ "$1" == "--help" ]; then 13 | usage 14 | exit 0 15 | elif [ "$1" == "" ]; then 16 | BRANCH="master" 17 | else 18 | BRANCH=$1 19 | fi 20 | 21 | if [ "$2" != "" ]; then 22 | VERSION=$2 23 | fi 24 | 25 | ROOT_DIR=$(cd $(dirname $0) && cd .. && pwd) 26 | BUILD_DIR="$ROOT_DIR/build" 27 | 28 | if [ -d "$BUILD_DIR" ]; then 29 | echo "Existing build directory detected at $BUILD_DIR" 30 | echo "Aborted." 31 | exit 1 32 | fi 33 | 34 | echo "This is an internal Intern release script!" 35 | echo -n "Press 'y' to create a new Intern Recorder release from branch $BRANCH" 36 | if [ "$VERSION" == "" ]; then 37 | echo "." 38 | else 39 | echo -e "\nwith version override $VERSION." 40 | fi 41 | echo "(You can abort pushing upstream later on if something goes wrong.)" 42 | read -s -n 1 43 | 44 | if [ "$REPLY" != "y" ]; then 45 | echo "Aborted." 46 | exit 0 47 | fi 48 | 49 | cd "$ROOT_DIR" 50 | mkdir "$BUILD_DIR" 51 | git clone --recursive git@github.com:theintern/recorder.git "$BUILD_DIR" 52 | 53 | cd "$BUILD_DIR" 54 | 55 | # Store the newly created tags and all updated branches outside of the loop so we can push/publish them all at once 56 | # at the end instead of having to guess that the second loop will run successfully after the first one 57 | RELEASE_TAG= 58 | PUSH_BRANCHES="$BRANCH" 59 | 60 | echo -e "\nBuilding $BRANCH branch...\n" 61 | 62 | git checkout $BRANCH 63 | 64 | # Get the version number for this release from package.json 65 | if [ "$VERSION" == "" ]; then 66 | VERSION=$(grep -o '"version": "[^"]*"' package.json | grep -o "$MATCH_VERSION") 67 | 68 | # Convert the version number to an array that we can use to generate the next release version number 69 | OLDIFS=$IFS 70 | IFS="." 71 | PRE_VERSION=($VERSION) 72 | IFS=$OLDIFS 73 | 74 | # This is a new major/minor release 75 | if [[ $VERSION =~ \.0$ ]]; then 76 | # We'll be creating a new minor release branch for this version for any future patch releases 77 | MAKE_BRANCH="${PRE_VERSION[0]}.${PRE_VERSION[1]}" 78 | BRANCH_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1))-pre" 79 | MANIFEST_BRANCH_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1)).0" 80 | 81 | # The next release is usually going to be a minor release; if the next version is to be a major release, 82 | # the package version will need to be manually updated in Git before release 83 | MANIFEST_PRE_VERSION="${PRE_VERSION[0]}.$((PRE_VERSION[1] + 1)).0.0" 84 | PRE_VERSION="${PRE_VERSION[0]}.$((PRE_VERSION[1] + 1)).0-pre" 85 | 86 | # This is a new patch release 87 | else 88 | # Patch releases do not get a branch 89 | MAKE_BRANCH= 90 | BRANCH_VERSION= 91 | MANIFEST_BRANCH_VERSION= 92 | 93 | # The next release version will always be another patch version 94 | MANIFEST_PRE_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1)).0" 95 | PRE_VERSION="${PRE_VERSION[0]}.${PRE_VERSION[1]}.$((PRE_VERSION[2] + 1))-pre" 96 | fi 97 | else 98 | MAKE_BRANCH= 99 | BRANCH_VERSION= 100 | MANIFEST_BRANCH_VERSION= 101 | PRE_VERSION=$(grep -o '"version": "[^"]*"' package.json | grep -o "$MATCH_VERSION") 102 | PRE_VERSION="$PRE_VERSION-pre" 103 | 104 | MANIFEST_VERSION=$(grep -o '"version": "[^"]*"' manifest.json | grep -o "$MATCH_VERSION") 105 | 106 | # Convert the version number to an array that we can use to generate the next release version number 107 | OLDIFS=$IFS 108 | IFS="." 109 | MANIFEST_PRE_VERSION=($MANIFEST_VERSION) 110 | IFS=$OLDIFS 111 | 112 | # Manifest needs the 4th version number incremented on each pre-release. The currently committed version in 113 | # manifest.json is used for this release, then it is incremented immediately for the next pre-release (or final 114 | # release). e.g.: 115 | # 1.0.0-alpha.1 -> 1.0.0.0, pre is 1.0.0.1 116 | # 1.0.0-alpha.2 -> 1.0.0.1, pre is 1.0.0.2 117 | # 1.0.0 -> 1.0.0.2, pre is 1.0.1.0 118 | MANIFEST_PRE_VERSION="${MANIFEST_PRE_VERSION[0]}.${MANIFEST_PRE_VERSION[1]}.${MANIFEST_PRE_VERSION[2]}.$((MANIFEST_PRE_VERSION[3] + 1))" 119 | fi 120 | 121 | TAG_VERSION=$VERSION 122 | RELEASE_TAG="$TAG_VERSION" 123 | 124 | # At this point: 125 | # $VERSION is the version of Intern that is being released; 126 | # $TAG_VERSION is the name that will be used for the Git tag for the release 127 | # $PRE_VERSION is the next pre-release version of Intern that will be set on the original branch after tagging 128 | # $MAKE_BRANCH is the name of the new minor release branch that should be created (if this is not a patch release) 129 | # $BRANCH_VERSION is the pre-release version of Intern that will be set on the minor release branch 130 | # $MANIFEST_* are the same versions as the unprefixed ones, except using a monotonically increasing fourth version 131 | # number instead of a semver prerelease suffix 132 | 133 | # Something is messed up and this release has already happened 134 | if [ $(git tag |grep -c "^$TAG_VERSION$") -gt 0 ]; then 135 | echo -e "\nTag $TAG_VERSION already exists! Please check the branch.\n" 136 | exit 1 137 | fi 138 | 139 | # Set the package version to release version 140 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json 141 | sed -i -e "s/\"version_name\": \"[^\"]*\"/\"version_name\": \"$VERSION\"/" manifest.json 142 | 143 | # Fix the Git-based dependencies to specific commit IDs 144 | echo -e "\nFixing dependency commits...\n" 145 | for DEP in dojo; do 146 | DEP_URL=$(grep -o "\"$DEP\": \"[^\"]*\"" package.json |grep -o 'https://[^"]*' |sed -e 's/\/archive.*//') 147 | COMMIT=$(grep -o "\"$DEP\": \"[^\"]*\"" package.json |grep -o 'https://[^"]*' |sed -e 's/.*archive\/\(.*\)\.tar\.gz/\1/') 148 | if [ "$DEP_URL" != "" ]; then 149 | if [[ "$COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then 150 | echo -e "\nDependency $DEP is already fixed to $COMMIT\n" 151 | else 152 | mkdir "$BUILD_DIR/.dep" 153 | git clone --single-branch --depth 1 --branch=$COMMIT "$DEP_URL.git" "$BUILD_DIR/.dep" 154 | cd "$BUILD_DIR/.dep" 155 | COMMIT=$(git log -n 1 --format='%H') 156 | cd "$BUILD_DIR" 157 | rm -rf "$BUILD_DIR/.dep" 158 | DEP_URL=$(echo $DEP_URL |sed -e 's/[\/&]/\\&/g') 159 | echo -e "\nFixing dependency $DEP to commit $COMMIT...\n" 160 | sed -i -e "s/\(\"$DEP\":\) \"[^\"]*\"/\1 \"$DEP_URL\/archive\/$COMMIT.tar.gz\"/" package.json 161 | fi 162 | fi 163 | done 164 | 165 | # Commit the new release to Git 166 | git commit -m "Updating metadata for $VERSION" package.json manifest.json 167 | git tag -a -m "Release $VERSION" $TAG_VERSION 168 | 169 | # Check out the previous package.json to get rid of the fixed dependencies 170 | git checkout HEAD^ package.json 171 | git reset package.json 172 | 173 | # Set the package version to next pre-release version 174 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$PRE_VERSION\"/" package.json 175 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$MANIFEST_PRE_VERSION\"/" manifest.json 176 | sed -i -e "s/\"version_name\": \"[^\"]*\"/\"version_name\": \"$PRE_VERSION\"/" manifest.json 177 | 178 | # Commit the pre-release to Git 179 | git commit -m "Updating source version to $PRE_VERSION" package.json manifest.json 180 | 181 | # If this is a major/minor release, we also create a new branch for it 182 | if [ "$MAKE_BRANCH" != "" ]; then 183 | # Create the new branch starting at the tagged release version 184 | git checkout -b $MAKE_BRANCH $TAG_VERSION 185 | 186 | # Set the package version to the next patch pre-release version 187 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$BRANCH_VERSION\"/" package.json 188 | sed -i -e "s/\"version\": \"[^\"]*\"/\"version\": \"$MANIFEST_BRANCH_VERSION\"/" manifest.json 189 | sed -i -e "s/\"version_name\": \"[^\"]*\"/\"version_name\": \"$BRANCH_VERSION\"/" manifest.json 190 | 191 | # Commit the pre-release to Git 192 | git commit -m "Updating source version to $BRANCH_VERSION" package.json manifest.json 193 | 194 | # Store the branch as one that needs to be pushed when we are ready to deploy the release 195 | PUSH_BRANCHES="$PUSH_BRANCHES $MAKE_BRANCH" 196 | fi 197 | 198 | echo -e "\nDone!\n" 199 | 200 | echo "Please confirm packaging success, then press 'y', ENTER to build release archive," 201 | echo "push tags $RELEASE_TAG, and upload, or any other key to bail." 202 | read -p "> " 203 | 204 | if [ "$REPLY" != "y" ]; then 205 | echo "Aborted." 206 | exit 0 207 | fi 208 | 209 | for BRANCH in $PUSH_BRANCHES; do 210 | git push origin $BRANCH 211 | done 212 | 213 | git push origin --tags 214 | 215 | git checkout $RELEASE_TAG 216 | zip -9r $ROOT_DIR/recorder-$RELEASE_TAG.zip . -x@.zipignore 217 | 218 | cd "$ROOT_DIR" 219 | rm -rf "$BUILD_DIR" 220 | 221 | echo -e "\nAll done! Yay!" 222 | -------------------------------------------------------------------------------- /tests/data/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Default & <b>default</b> 6 | 7 | 9 | 10 |

Are you kay-o?
11 | If you all right, that's great.
12 | zoomer!

13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/data/elements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Find elements 6 | 7 | 8 | What a cute, red cap. 9 | 10 |
a
11 | 12 |
b2
13 |
b1
14 | What a cute, yellow 15 | backpack. 16 | What a cute, yellow backpack. 17 | What a cute
, yellow
backpack.
18 | 19 | 20 | 21 | 22 |
23 | f 24 | g 25 |
26 | 27 |
28 | What a cute, red cap. 29 | What a cute, 30 | red cap. 31 | What a cap. 32 | What a cute, striped shirt. 33 | 34 | 35 |
36 | 37 |

I've come up with another wacky invention that I think has real potential.
38 | Maybe you won't, but anyway...
39 | it's called the “Gourmet Yogurt Machine.”
40 | It makes many different flavors of yogurt.
41 | The only problem is, right now, 42 | it can only make trout-flavored yogurt...
43 | So, I'm having the machine delivered to you via Escargo Express.
44 | It's coming “Neglected Class.”

45 | 46 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /tests/data/frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Find elements 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/data/subframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Find elements 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/data/superframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Find elements 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/filetypes.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.txt' { 2 | const value: string; 3 | export = value; 4 | } 5 | 6 | declare module '*.ts' { 7 | const value: string; 8 | export = value; 9 | } 10 | -------------------------------------------------------------------------------- /tests/integration/blank.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('frame.html', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/frame.html'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /tests/integration/callback.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('callback', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/frame.html') 12 | .then(() => {}); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/integration/click.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('click', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/elements.html') 12 | .findByXpath('id("b2")') 13 | .moveMouseTo(59, 12) 14 | .clickMouseButton(0); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/integration/doubleClick.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('doubleClick', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/elements.html') 12 | .findByXpath('id("b2")') 13 | .moveMouseTo(59, 12) 14 | .doubleClick(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/integration/drag.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('drag', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/elements.html') 12 | .findByXpath('id("b2")') 13 | .moveMouseTo(9, 9) 14 | .pressMouseButton(0) 15 | .moveMouseTo(10, 9) 16 | .end() 17 | .findByXpath('/HTML/BODY[1]') 18 | .moveMouseTo(32, 43) 19 | .releaseMouseButton(0); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/integration/findDisplayed.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('findDisplayed', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/elements.html') 12 | .findDisplayedByXpath('id("b2")') 13 | .moveMouseTo(12, 23); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/integration/frame.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('frame', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/superframe.html') 12 | .switchToFrame(1) 13 | .switchToFrame(0) 14 | .findByXpath('id("b2")') 15 | .moveMouseTo(8, 11) 16 | .clickMouseButton(0) 17 | .end() 18 | .switchToFrame(null) 19 | .switchToFrame(1) 20 | .switchToFrame(1) 21 | .findByXpath('/HTML/BODY[1]/P') 22 | .moveMouseTo(22, 27) 23 | .clickMouseButton(0); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/integration/hotkey.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('hotkey', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/frame.html') 12 | .pressKeys(''); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/integration/mouseMove.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('mouseMove', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/elements.html') 12 | .findByXpath('id("b2")') 13 | .moveMouseTo(59, 12); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/integration/navigation.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('navigation', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/elements.html') 12 | .findByXpath('id("b2")') 13 | .moveMouseTo(5, 11) 14 | .clickMouseButton(0) 15 | .end() 16 | .refresh() 17 | .get('http://localhost:9000/tests/data/frame.html') 18 | .get('http://localhost:9000/tests/data/frame.html#test') 19 | .get('http://localhost:9000/tests/data/elements.html'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/integration/newTest.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('newTest', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/frame.html') 12 | .then(() => {}); 13 | }); 14 | 15 | test('Test 2', tst => { 16 | return tst.remote 17 | .get('http://localhost:9000/tests/data/frame.html') 18 | .switchToFrame(0) 19 | .findByXpath('id("b2")') 20 | .moveMouseTo(12, 23) 21 | .clickMouseButton(0) 22 | .end() 23 | .switchToFrame(null); 24 | }); 25 | 26 | test('Test 3', tst => { 27 | return tst.remote 28 | .get('http://localhost:9000/tests/data/frame.html') 29 | .then(() => {}); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/integration/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@theintern/dev/tslint.json", 3 | "rules": { 4 | "indent": [true, "spaces", 2] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/integration/type.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getPlugin('interface.tdd'); 2 | 3 | // Uncomment the line below to use chai's 'assert' interface. 4 | // const { assert } = intern.getPlugin('chai'); 5 | 6 | // Export the suite to ensure that it's built as a module rather 7 | // than a simple script. 8 | export default suite('type', () => { 9 | test('Test 1', tst => { 10 | return tst.remote 11 | .get('http://localhost:9000/tests/data/frame.html') 12 | .pressKeys('Hello, world!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/support/mockChromeApi.ts: -------------------------------------------------------------------------------- 1 | import { createMockMethod, pullFromArray, Method } from './util'; 2 | 3 | export const testHost = 'http://localhost:9000/tests/data'; 4 | export const testPage = 'http://localhost:9000/tests/data/frame.html'; 5 | 6 | export default class Chrome { 7 | devtools = { 8 | inspectedWindow: { 9 | tabId: 1692485 10 | } 11 | }; 12 | 13 | downloads = { 14 | download: createMockMethod() 15 | }; 16 | 17 | runtime = { 18 | id: 'mock', 19 | onConnect: createListener(), 20 | connect: (_id: string, options: MockConnectInfo) => 21 | this.createPort(options.name!) 22 | }; 23 | 24 | webNavigation = { 25 | onCommitted: createListener(), 26 | onReferenceFragmentUpdated: createListener(), 27 | onHistoryStateUpdated: createListener() 28 | }; 29 | 30 | tabs = { 31 | executeScript: createMockMethod(), 32 | get: createMockMethod((tabId: number, callback: Function) => { 33 | const tabs: { [key: number]: object } = { 34 | 1: { url: testPage }, 35 | 2: { url: `${testHost}/elements.html` }, 36 | 3: { url: `${testHost}/superframe.html` } 37 | }; 38 | callback(tabs[tabId]); 39 | }) 40 | }; 41 | 42 | createButton(): Button { 43 | return { 44 | onClicked: createListener(), 45 | update: createMockMethod() 46 | }; 47 | } 48 | 49 | createPort(name: string) { 50 | const port: Port = { 51 | name, 52 | disconnect: createMockMethod(() => { 53 | port.onDisconnect.emit(); 54 | }), 55 | onDisconnect: createListener(), 56 | onMessage: createListener(), 57 | postMessage: createMockMethod() 58 | }; 59 | return port; 60 | } 61 | 62 | createPanel(): Panel { 63 | const buttons: Button[] = []; 64 | 65 | return { 66 | buttons, 67 | createStatusBarButton: createMockMethod( 68 | ( 69 | _iconPath: string, 70 | _tooltipText: string, 71 | _disabled: boolean 72 | ) => { 73 | const button = this.createButton(); 74 | buttons.push(button); 75 | return button; 76 | } 77 | ), 78 | onShown: createListener(), 79 | onHidden: createListener() 80 | }; 81 | } 82 | } 83 | 84 | interface MockConnectInfo { 85 | name?: string; 86 | } 87 | 88 | interface MockEvent { 89 | addListener(callback: Function): void; 90 | emit(data?: any): void; 91 | removeListener(callback: Function): void; 92 | } 93 | 94 | export interface Port { 95 | name: string; 96 | disconnect: Method<() => void>; 97 | onDisconnect: MockEvent; 98 | onMessage: MockEvent; 99 | postMessage: Method<(message: Object) => void>; 100 | } 101 | 102 | export interface Button { 103 | onClicked: MockEvent; 104 | update: Method< 105 | ( 106 | iconPath?: string | null, 107 | tooltipText?: string | null, 108 | disabled?: boolean | null 109 | ) => void 110 | >; 111 | } 112 | 113 | export interface Panel { 114 | buttons: Button[]; 115 | createStatusBarButton( 116 | iconPath: string, 117 | tooltipText: string, 118 | disabled: boolean 119 | ): Button; 120 | onShown: MockEvent; 121 | onHidden: MockEvent; 122 | } 123 | 124 | function createListener(): MockEvent { 125 | const listeners: Function[] = []; 126 | 127 | return { 128 | addListener(callback: Function) { 129 | listeners.push(callback); 130 | }, 131 | emit() { 132 | const self = this; 133 | const args = arguments; 134 | listeners.forEach(function(listener) { 135 | listener.apply(self, args); 136 | }); 137 | }, 138 | removeListener(callback: Function) { 139 | pullFromArray(listeners, callback); 140 | } 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /tests/support/mockDomApi.ts: -------------------------------------------------------------------------------- 1 | import { createMockMethod, pullFromArray, Method } from './util'; 2 | 3 | export interface Event { 4 | type: string; 5 | data?: object | null; 6 | source?: Window; 7 | buttons?: number; 8 | clientX?: number; 9 | clientY?: number; 10 | currentTarget?: Listener; 11 | target?: Element; 12 | } 13 | 14 | class Listener { 15 | listenerMap: { [name: string]: Function[] } = {}; 16 | 17 | addEventListener(eventName: string, listener: Function) { 18 | let listeners = this.listenerMap[eventName]; 19 | if (!listeners) { 20 | listeners = this.listenerMap[eventName] = []; 21 | } 22 | listeners.push(listener); 23 | } 24 | 25 | dispatchEvent(event: Event) { 26 | const listeners = this.listenerMap[event.type]; 27 | if (!listeners) { 28 | return; 29 | } 30 | 31 | listeners.forEach(listener => { 32 | event.currentTarget = this; 33 | listener.call(this, event); 34 | }); 35 | } 36 | 37 | removeEventListener(eventName: string, callback: Function) { 38 | const listeners = this.listenerMap[eventName]; 39 | if (listeners) { 40 | pullFromArray(listeners, callback); 41 | } 42 | } 43 | } 44 | 45 | export interface ElementProperties { 46 | nodeName: string; 47 | tagName: string; 48 | parentNode?: Element | Document | null; 49 | id?: string; 50 | getBoundingClientRect: () => { top: number; left: number }; 51 | previousElementSibling?: Element; 52 | stringValue?: string; 53 | value?: string; 54 | 'data-test'?: string; 55 | innerHTML?: string; 56 | } 57 | 58 | export interface MockEvent {} 59 | 60 | export class Element implements ElementProperties { 61 | nodeName: string; 62 | tagName: string; 63 | id?: string; 64 | parentNode?: Element | Document | null; 65 | getBoundingClientRect: () => { top: number; left: number }; 66 | nextElementSibling?: Element; 67 | previousElementSibling?: Element; 68 | stringValue?: string; 69 | value?: string; 70 | 'data-test': string; 71 | innerHTML?: string; 72 | 73 | checked?: boolean; 74 | onkeydown?: (event: Partial) => void; 75 | oninput?: (event: Partial) => void; 76 | onchange?: (event: Partial) => void; 77 | 78 | constructor(properties: Partial) { 79 | Object.assign( 80 | this, 81 | { 82 | nodeName: '', 83 | tagName: '', 84 | getBoundingClientRect: () => ({ top: 0, left: 0 }) 85 | }, 86 | properties 87 | ); 88 | } 89 | 90 | hasAttribute(attr: string) { 91 | return attr in this; 92 | } 93 | 94 | getAttribute(attr: string): string { 95 | return String(this[attr]); 96 | } 97 | } 98 | 99 | export interface XpathResult { 100 | (text: string, element: Element): { 101 | iterateNext(): null | {}; 102 | stringValue?: string; 103 | }; 104 | } 105 | 106 | export class Document extends Listener { 107 | elements: { [id: string]: Element } = {}; 108 | documentElement: Element; 109 | html: Element; 110 | body: Element; 111 | parentNode = null; 112 | 113 | constructor() { 114 | super(); 115 | this.documentElement = new Element({ 116 | nodeName: '#document', 117 | parentNode: null 118 | }); 119 | this.html = new Element({ 120 | nodeName: 'HTML', 121 | parentNode: this.documentElement, 122 | tagName: 'HTML' 123 | }); 124 | this.body = new Element({ 125 | nodeName: 'BODY', 126 | parentNode: this.html, 127 | previousElementSibling: new Element({ 128 | nodeName: 'HEAD', 129 | parentNode: this.html, 130 | tagName: 'HEAD' 131 | }), 132 | tagName: 'BODY' 133 | }); 134 | } 135 | 136 | elementsFromPoint() { 137 | return [this.body, this.html, this.documentElement]; 138 | } 139 | 140 | evaluate = createMockMethod( 141 | (text: string, element: Element) => { 142 | let i = 0; 143 | return { 144 | iterateNext() { 145 | if (text.indexOf('SINGLE') > -1 && i > 0) { 146 | return null; 147 | } 148 | 149 | ++i; 150 | return {}; 151 | }, 152 | stringValue: element.stringValue 153 | }; 154 | } 155 | ); 156 | 157 | getElementById(id: string) { 158 | if (id.indexOf('invalid') > -1) { 159 | return null; 160 | } 161 | 162 | if (!this.elements[id]) { 163 | this.elements[id] = new Element({ value: '' }); 164 | } 165 | 166 | return this.elements[id]; 167 | } 168 | } 169 | 170 | export default class Window extends Listener { 171 | document: Document; 172 | frames: Window[]; 173 | navigator: { platform: string | null }; 174 | postMessage: Method<(message: Object) => void>; 175 | XPathResult = { 176 | ANY_TYPE: 0, 177 | NUMBER_TYPE: 1, 178 | STRING_TYPE: 2, 179 | BOOLEAN_TYPE: 3, 180 | UNORDERED_NODE_ITERATOR_TYPE: 4, 181 | ORDERED_NODE_ITERATOR_TYPE: 5, 182 | UNORDERED_NODE_SNAPSHOT_TYPE: 6, 183 | ORDERED_NODE_SNAPSHOT_TYPE: 7, 184 | ANY_UNORDERED_NODE_TYPE: 8, 185 | FIRST_ORDERED_NODE_TYPE: 9 186 | }; 187 | parent: Window; 188 | top: Window; 189 | 190 | constructor(platform: string | null, isChildWindow = false) { 191 | super(); 192 | 193 | this.document = new Document(); 194 | this.navigator = { platform }; 195 | this.postMessage = createMockMethod(); 196 | this.frames = []; 197 | 198 | if (isChildWindow) { 199 | this.parent = this.top = new Window(platform); 200 | } else { 201 | this.parent = this.top = this; 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /tests/support/mockStorageApi.ts: -------------------------------------------------------------------------------- 1 | export default class MockStorageApi implements Storage { 2 | store: Store; 3 | 4 | constructor(store?: Store) { 5 | this.store = store || {}; 6 | } 7 | 8 | get length() { 9 | if (!this.store) { 10 | return 0; 11 | } 12 | return Object.keys(this.store).length; 13 | } 14 | 15 | clear() { 16 | this.store = {}; 17 | } 18 | 19 | key(index: number) { 20 | return Object.keys(this.store)[index]; 21 | } 22 | 23 | getItem(key: string) { 24 | return ( 25 | (Object.prototype.hasOwnProperty.call(this.store, key) && 26 | this.store[key]) || 27 | null 28 | ); 29 | } 30 | 31 | removeItem(key: string) { 32 | delete this.store[key]; 33 | } 34 | 35 | setItem(key: string, value: any) { 36 | this.store[key] = value; 37 | } 38 | 39 | [key: string]: any; 40 | [index: number]: string; 41 | } 42 | 43 | export interface Store { 44 | [key: string]: string; 45 | } 46 | -------------------------------------------------------------------------------- /tests/support/util.ts: -------------------------------------------------------------------------------- 1 | export type Method = F & { 2 | calls: any[]; 3 | clear(): void; 4 | }; 5 | 6 | export function createMockMethod void>( 7 | impl?: T 8 | ): Method { 9 | const method = >(function(this: any) { 10 | method.calls.push(Array.prototype.slice.call(arguments, 0)); 11 | if (impl) { 12 | return impl.apply(this, arguments); 13 | } 14 | }); 15 | method.calls = []; 16 | method.clear = () => { 17 | method.calls.splice(0, Infinity); 18 | }; 19 | return method; 20 | } 21 | 22 | export function mock( 23 | obj: T, 24 | methodName: M, 25 | applyOriginal = false 26 | ) { 27 | const originalMethod = obj[methodName]; 28 | const method = (obj[methodName] = >function(this: any) { 29 | method.calls.push(Array.prototype.slice.call(arguments, 0)); 30 | if (applyOriginal) { 31 | return (originalMethod).apply(this, arguments); 32 | } 33 | }); 34 | method.calls = []; 35 | method.clear = () => { 36 | method.calls.splice(0, Infinity); 37 | }; 38 | return { 39 | method, 40 | remove() { 41 | obj[methodName] = originalMethod; 42 | this.remove = () => {}; 43 | } 44 | }; 45 | } 46 | 47 | export function pullFromArray(arr: T[], item: T) { 48 | const index = arr.indexOf(item); 49 | if (index !== -1) { 50 | arr.splice(index, 1); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./_tests", 5 | "types": ["intern"] 6 | }, 7 | "include": ["./integration/**.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["es2015", "dom"], 5 | "module": "commonjs", 6 | "outDir": "./_tests", 7 | "types": ["intern", "chrome"] 8 | }, 9 | "include": ["./**/**.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tests/unit/EventProxy.ts: -------------------------------------------------------------------------------- 1 | import { mock } from '../support/util'; 2 | import MockChrome, { Port } from '../support/mockChromeApi'; 3 | import MockWindow, { Document, Event, Element } from '../support/mockDomApi'; 4 | import EventProxy from '../../src/EventProxy'; 5 | 6 | const { assert } = intern.getPlugin('chai'); 7 | const { registerSuite } = intern.getPlugin('interface.object'); 8 | 9 | function createEvent(event?: Event) { 10 | return Object.assign( 11 | { 12 | altKey: false, 13 | button: 0, 14 | buttons: 0, 15 | ctrlKey: false, 16 | clientX: 3, 17 | clientY: 4, 18 | elementX: 1, 19 | elementY: 2, 20 | key: null, 21 | location: null, 22 | metaKey: false, 23 | shiftKey: false, 24 | target: { 25 | getBoundingClientRect: function() { 26 | return { left: 1, top: 2 }; 27 | }, 28 | nodeName: 'SINGLE', 29 | parentNode: null, 30 | tagName: 'SINGLE' 31 | }, 32 | type: 'mousemove' 33 | }, 34 | event 35 | ); 36 | } 37 | 38 | registerSuite('EventProxy', () => { 39 | let chrome: MockChrome; 40 | let document: Document; 41 | let eventProxyPort: Port; 42 | let eventProxy: EventProxy; 43 | let window: MockWindow; 44 | 45 | return { 46 | beforeEach() { 47 | chrome = new MockChrome(); 48 | window = new MockWindow(''); 49 | document = window.document; 50 | eventProxy = new EventProxy( 51 | window, 52 | document, 53 | chrome 54 | ); 55 | eventProxy.connect(); 56 | eventProxy.setStrategy('xpath'); 57 | // eventProxy.port will have come from the mock Chrome, so it will be a Port 58 | eventProxyPort = eventProxy.port!; 59 | }, 60 | 61 | after() { 62 | (chrome) = (window) = (eventProxy) = (eventProxyPort) = null; 63 | }, 64 | 65 | tests: { 66 | 'port communication'() { 67 | assert.throws(function() { 68 | eventProxyPort.onMessage.emit({ method: 'not-a-method' }); 69 | }, 'Method "not-a-method" does not exist'); 70 | 71 | const { method: setStrategy } = mock(eventProxy, 'setStrategy'); 72 | eventProxyPort.onMessage.emit({ 73 | method: 'setStrategy', 74 | args: ['foo'] 75 | }); 76 | eventProxyPort.onMessage.emit({ method: 'setStrategy' }); 77 | assert.deepEqual( 78 | setStrategy.calls, 79 | [['foo'], []], 80 | 'Valid calls from communication port should be executed on the proxy' 81 | ); 82 | }, 83 | 84 | 'port disconnect/reconnect': function() { 85 | assert.ok(eventProxy.port); 86 | assert.lengthOf(eventProxyPort.disconnect.calls, 0); 87 | eventProxy.connect(); 88 | assert.lengthOf(eventProxyPort.disconnect.calls, 1); 89 | assert.notStrictEqual( 90 | eventProxyPort, 91 | eventProxy.port, 92 | 'Reconnection should replace an existing port' 93 | ); 94 | 95 | const newProxyPort: Port = eventProxy.port!; 96 | eventProxyPort.postMessage.clear(); 97 | newProxyPort.postMessage.clear(); 98 | document.dispatchEvent(createEvent()); 99 | 100 | assert.lengthOf( 101 | eventProxyPort.postMessage.calls, 102 | 0, 103 | 'Old port should not receive events' 104 | ); 105 | assert.lengthOf( 106 | newProxyPort.postMessage.calls, 107 | 1, 108 | 'New port should receive events' 109 | ); 110 | }, 111 | 112 | 'send event': { 113 | 'top window': function() { 114 | const detail = { test: true }; 115 | 116 | eventProxyPort.postMessage.clear(); 117 | eventProxy.send(detail); 118 | assert.deepEqual(eventProxyPort.postMessage.calls, [ 119 | [{ method: 'recordEvent', args: [detail] }] 120 | ]); 121 | }, 122 | 123 | 'inline frame sender': function() { 124 | const detail = { test: true }; 125 | const childWindow = new MockWindow(null, true); 126 | const childEventProxy = new EventProxy( 127 | childWindow, 128 | childWindow.document, 129 | chrome 130 | ); 131 | childEventProxy.connect(); 132 | childEventProxy.setStrategy('xpath'); 133 | 134 | const childEventProxyPort: Port = childEventProxy.port!; 135 | childEventProxy.send(detail); 136 | assert.lengthOf( 137 | childEventProxyPort.postMessage.calls, 138 | 0, 139 | 'Messages from child frames should not go to the chrome runtime port' 140 | ); 141 | assert.deepEqual( 142 | childWindow.parent.postMessage.calls, 143 | [[{ method: 'recordEvent', detail: detail }, '*']], 144 | 'Messages from child frames should be sent to parent window' 145 | ); 146 | }, 147 | 148 | 'inline frame recipient': function() { 149 | const { method: send } = mock(eventProxy, 'send'); 150 | 151 | const sourceWindow = new MockWindow(null); 152 | 153 | window.frames = [new MockWindow(null), sourceWindow]; 154 | window.dispatchEvent({ 155 | data: null, 156 | type: 'message', 157 | source: sourceWindow 158 | }); 159 | window.dispatchEvent({ 160 | data: { method: 'wrong-method' }, 161 | type: 'message', 162 | source: sourceWindow 163 | }); 164 | window.dispatchEvent({ 165 | data: { method: 'recordEvent', detail: null }, 166 | type: 'message', 167 | source: sourceWindow 168 | }); 169 | 170 | assert.lengthOf( 171 | send.calls, 172 | 0, 173 | 'Unrelated or malformed messages should not be processed' 174 | ); 175 | 176 | window.dispatchEvent({ 177 | data: { 178 | method: 'recordEvent', 179 | detail: { target: '/HTML/BODY[1]', targetFrame: [] } 180 | }, 181 | type: 'message', 182 | source: sourceWindow 183 | }); 184 | 185 | assert.deepEqual(send.calls, [ 186 | [{ target: '/HTML/BODY[1]', targetFrame: [1] }] 187 | ]); 188 | } 189 | }, 190 | 191 | '#getElementTextPath': function() { 192 | const element1 = new Element({ 193 | nodeName: 'BODY', 194 | parentNode: document.html, 195 | previousElementSibling: new Element({ 196 | nodeName: 'HEAD', 197 | parentNode: document.html 198 | }), 199 | stringValue: 'Hello, world' 200 | }); 201 | 202 | assert.strictEqual( 203 | eventProxy.getElementTextPath(element1), 204 | '/HTML/BODY[1][normalize-space(string())="Hello, world"]' 205 | ); 206 | 207 | const element2 = new Element({ 208 | nodeName: 'SINGLE', 209 | parentNode: document.html, 210 | previousElementSibling: new Element({ 211 | nodeName: 'HEAD', 212 | parentNode: document.html 213 | }), 214 | stringValue: 'Hello, world' 215 | }); 216 | 217 | assert.strictEqual( 218 | eventProxy.getElementTextPath(element2), 219 | '//SINGLE[normalize-space(string())="Hello, world"]' 220 | ); 221 | }, 222 | 223 | '#getElementXPath': function() { 224 | const body = new Element({ 225 | nodeName: 'BODY', 226 | parentNode: document.html, 227 | previousElementSibling: new Element({ 228 | nodeName: 'HEAD', 229 | parentNode: document.html 230 | }) 231 | }); 232 | 233 | const element1 = new Element({ 234 | nodeName: 'DIV', 235 | parentNode: body, 236 | previousElementSibling: new Element({ 237 | nodeName: 'DIV', 238 | parentNode: body 239 | }) 240 | }); 241 | 242 | assert.strictEqual( 243 | eventProxy.getElementXPath(element1), 244 | '/HTML/BODY[1]/DIV[2]' 245 | ); 246 | 247 | const element2 = new Element({ 248 | id: 'test', 249 | nodeName: 'DIV', 250 | parentNode: body 251 | }); 252 | assert.strictEqual( 253 | eventProxy.getElementXPath(element2), 254 | 'id("test")' 255 | ); 256 | assert.strictEqual( 257 | eventProxy.getElementXPath(element2, true), 258 | '/HTML/BODY[1]/DIV' 259 | ); 260 | 261 | const element3 = new Element({ 262 | id: 'test', 263 | 'data-test': 'bar', 264 | nodeName: 'DIV', 265 | parentNode: body 266 | }); 267 | eventProxy.setCustomAttribute('data-test'); 268 | assert.strictEqual( 269 | eventProxy.getElementXPath(element3), 270 | '[data-test="bar"]' 271 | ); 272 | assert.strictEqual( 273 | eventProxy.getElementXPath(element3, true), 274 | '[data-test="bar"]' 275 | ); 276 | }, 277 | 278 | 'click event': function() { 279 | const { method: send } = mock(eventProxy, 'send'); 280 | 281 | document.dispatchEvent( 282 | createEvent({ type: 'mousedown', buttons: 1 }) 283 | ); 284 | document.dispatchEvent( 285 | createEvent({ type: 'mouseup', clientX: 55, clientY: 55 }) 286 | ); 287 | document.dispatchEvent( 288 | createEvent({ type: 'click', clientX: 55, clientY: 55 }) 289 | ); 290 | 291 | assert.lengthOf( 292 | send.calls, 293 | 2, 294 | 'Click event should not be transmitted when it does not match heuristics for a click' 295 | ); 296 | assert.propertyVal(send.calls[0][0], 'type', 'mousedown'); 297 | assert.propertyVal(send.calls[1][0], 'type', 'mouseup'); 298 | 299 | send.clear(); 300 | document.dispatchEvent( 301 | createEvent({ type: 'mousedown', buttons: 1 }) 302 | ); 303 | document.dispatchEvent(createEvent({ type: 'mouseup' })); 304 | document.dispatchEvent(createEvent({ type: 'click' })); 305 | 306 | assert.lengthOf( 307 | send.calls, 308 | 3, 309 | 'Click event should be transmitted when it matches heuristics for a click' 310 | ); 311 | assert.propertyVal(send.calls[0][0], 'type', 'mousedown'); 312 | assert.propertyVal(send.calls[1][0], 'type', 'mouseup'); 313 | assert.propertyVal(send.calls[2][0], 'type', 'click'); 314 | 315 | assert.strictEqual( 316 | send.calls[1][0].target, 317 | '/HTML/BODY[1]', 318 | 'Mouseup should not use the target the element under the mouse since ' + 319 | 'it may have been dragged and dropped and this action would be recorded incorrectly otherwise' 320 | ); 321 | }, 322 | 323 | '#setStrategy': function() { 324 | assert.throws(function() { 325 | eventProxy.setStrategy('invalid'); 326 | }, 'Invalid strategy'); 327 | 328 | eventProxy.setStrategy('xpath'); 329 | assert.strictEqual( 330 | eventProxy.getTarget, 331 | eventProxy.getElementXPath 332 | ); 333 | 334 | eventProxy.setStrategy('text'); 335 | assert.strictEqual( 336 | eventProxy.getTarget, 337 | eventProxy.getElementTextPath 338 | ); 339 | }, 340 | 341 | '#setCustomAttribute': function() { 342 | eventProxy.setCustomAttribute('foo'); 343 | assert.strictEqual( 344 | eventProxy.getTarget, 345 | eventProxy.getElementXPath 346 | ); 347 | } 348 | } 349 | }; 350 | }); 351 | -------------------------------------------------------------------------------- /tests/unit/Recorder.ts: -------------------------------------------------------------------------------- 1 | import { mock } from '../support/util'; 2 | import MockChrome, { testPage, testHost, Port } from '../support/mockChromeApi'; 3 | import MockStorage from '../support/mockStorageApi'; 4 | import Recorder, { 5 | HotKeys, 6 | RecorderEvent, 7 | RecorderMouseEvent, 8 | RecorderKeyboardEvent 9 | } from '../../src/Recorder'; 10 | import * as BlankText from '../integration/blank.ts'; 11 | import * as CallbackText from '../integration/callback.ts'; 12 | import * as ClickText from '../integration/click.ts'; 13 | import * as DoubleClickText from '../integration/doubleClick.ts'; 14 | import * as DragText from '../integration/drag.ts'; 15 | import * as FindDisplayedText from '../integration/findDisplayed.ts'; 16 | import * as FrameText from '../integration/frame.ts'; 17 | import * as HotkeyText from '../integration/hotkey.ts'; 18 | import * as MouseMoveText from '../integration/mouseMove.ts'; 19 | import * as NavigationText from '../integration/navigation.ts'; 20 | import * as NewTestText from '../integration/newTest.ts'; 21 | import * as TypeText from '../integration/type.ts'; 22 | 23 | const { assert } = intern.getPlugin('chai'); 24 | const { registerSuite } = intern.getPlugin('interface.object'); 25 | 26 | const testData: { [key: string]: string } = { 27 | blank: BlankText, 28 | callback: CallbackText, 29 | click: ClickText, 30 | doubleClick: DoubleClickText, 31 | drag: DragText, 32 | findDisplayed: FindDisplayedText, 33 | frame: FrameText, 34 | hotkey: HotkeyText, 35 | mouseMove: MouseMoveText, 36 | navigation: NavigationText, 37 | newTest: NewTestText, 38 | type: TypeText 39 | }; 40 | 41 | function assertScriptValue(port: Port, value: string, assertMessage?: string) { 42 | assert.strictEqual(getLastScriptValue(port), value, assertMessage); 43 | } 44 | 45 | function getLastScriptValue(port: Port) { 46 | for (let i = port.postMessage.calls.length - 1; i >= 0; --i) { 47 | const message = port.postMessage.calls[i][0]; 48 | if (message.method === 'setScript') { 49 | return message.args[0]; 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | 56 | function createEvent(event: Partial): RecorderMouseEvent; 57 | function createEvent( 58 | event: Partial 59 | ): RecorderKeyboardEvent; 60 | function createEvent(event: Partial): RecorderEvent { 61 | if (!event || !event.type) { 62 | throw new Error( 63 | 'At least "type" is required to generate an event object' 64 | ); 65 | } 66 | 67 | return Object.assign( 68 | { 69 | altKey: false, 70 | button: 0, 71 | buttons: 0, 72 | clientX: 59, 73 | clientY: 12, 74 | ctrlKey: false, 75 | elementX: 59, 76 | elementY: 12, 77 | location: 0, 78 | metaKey: false, 79 | shiftKey: false, 80 | target: 'target', 81 | targetFrame: [] 82 | }, 83 | event 84 | ); 85 | } 86 | 87 | function mockBlobAndUrl() { 88 | const win: any = typeof global === 'undefined' ? window : global; 89 | 90 | let originalBlob: Blob; 91 | if (win.Blob !== 'undefined') { 92 | originalBlob = win.Blob; 93 | } 94 | 95 | win.Blob = function(this: any, data: object, options: { type: string }) { 96 | this.data = [...[data]].join(''); 97 | this.type = options.type; 98 | }; 99 | 100 | let originalUrl: URL; 101 | if (win.URL !== 'undefined') { 102 | originalUrl = win.URL; 103 | } 104 | 105 | win.URL = { 106 | blob: null, 107 | createObjectURL(blob: Blob) { 108 | this.blob = blob; 109 | return 'blob://test'; 110 | }, 111 | revokeObjectURL(url: string) { 112 | if (url === 'blob://test') { 113 | this.blob = null; 114 | } 115 | } 116 | }; 117 | 118 | const mock = { 119 | URL: win.URL, 120 | remove() { 121 | if (originalBlob) { 122 | win.Blob = originalBlob; 123 | } 124 | if (originalUrl) { 125 | win.URL = URL; 126 | } 127 | 128 | mock.remove = () => {}; 129 | } 130 | }; 131 | 132 | return mock; 133 | } 134 | 135 | registerSuite('Recorder', () => { 136 | let chrome: MockChrome; 137 | let devToolsPort: Port; 138 | let recorder: Recorder; 139 | let storage: MockStorage; 140 | 141 | return { 142 | beforeEach() { 143 | chrome = new MockChrome(); 144 | devToolsPort = chrome.createPort('recorderProxy'); 145 | storage = new MockStorage(); 146 | recorder = new Recorder(chrome, storage); 147 | chrome.runtime.onConnect.emit(devToolsPort); 148 | }, 149 | 150 | after() { 151 | (chrome) = (devToolsPort) = (storage) = (recorder) = null; 152 | }, 153 | 154 | tests: { 155 | 'error handling': { 156 | construction() { 157 | /* jshint nonew:false */ 158 | assert.throws(function() { 159 | new Recorder(); 160 | }, 'Chrome API must be provided'); 161 | assert.throws(function() { 162 | new Recorder(chrome); 163 | }, 'Storage API must be provided'); 164 | } 165 | }, 166 | 167 | 'port messaging': { 168 | 'invalid RPC': function() { 169 | assert.throws(function() { 170 | devToolsPort.onMessage.emit({ 171 | method: 'invalidMethod' 172 | }); 173 | }, 'Method "invalidMethod" does not exist'); 174 | }, 175 | 176 | 'valid RPC': function() { 177 | const { method: setScript } = mock(recorder, 'setScript'); 178 | devToolsPort.onMessage.emit({ 179 | method: 'setScript', 180 | args: ['test'] 181 | }); 182 | assert.deepEqual(setScript.calls, [['test']]); 183 | setScript.clear(); 184 | devToolsPort.onMessage.emit({ method: 'setScript' }); 185 | assert.deepEqual( 186 | setScript.calls, 187 | [[]], 188 | 'Calls missing args should not fail' 189 | ); 190 | }, 191 | 192 | 'disconnect/reconnect': function() { 193 | recorder.setScript('test'); 194 | assertScriptValue( 195 | devToolsPort, 196 | 'test', 197 | 'Verify that the setScript function does send to the ' + 198 | 'devtools port when it is connected' 199 | ); 200 | 201 | recorder.setTabId(1); 202 | recorder.toggleState(); 203 | assert.isTrue(recorder.recording); 204 | devToolsPort.postMessage.clear(); 205 | devToolsPort.onDisconnect.emit(); 206 | recorder.setScript('test2'); 207 | assert.lengthOf( 208 | devToolsPort.postMessage.calls, 209 | 0, 210 | 'Messages should not be sent to port once it disconnects' 211 | ); 212 | assert.isFalse( 213 | recorder.recording, 214 | 'Recorder should stop recording if devtools disconnects' 215 | ); 216 | 217 | chrome.runtime.onConnect.emit(devToolsPort); 218 | const actual = devToolsPort.postMessage.calls; 219 | const expected: { 220 | method: string; 221 | args: any[]; 222 | }[][] = Object.keys(recorder.hotkeys).map(hotkeyId => [ 223 | { 224 | method: 'setHotkey', 225 | args: [ 226 | hotkeyId, 227 | recorder.hotkeys[hotkeyId] 228 | ] 229 | } 230 | ]); 231 | expected.push([{ method: 'setScript', args: ['test2'] }]); 232 | expected.push([{ method: 'setRecording', args: [false] }]); 233 | expected.push([{ method: 'setStrategy', args: ['xpath'] }]); 234 | expected.push([ 235 | { method: 'setFindDisplayed', args: [false] } 236 | ]); 237 | expected.push([ 238 | { method: 'setCustomAttribute', args: [null] } 239 | ]); 240 | assert.sameDeepMembers( 241 | actual, 242 | expected, 243 | 'Information about the current recorder state should be ' + 244 | 'sent to the UI when it connects to the Recorder' 245 | ); 246 | }, 247 | 248 | 'event proxy port': function() { 249 | const { method: recordEvent } = mock( 250 | recorder, 251 | 'recordEvent' 252 | ); 253 | const eventProxyPort = chrome.createPort('eventProxy'); 254 | chrome.runtime.onConnect.emit(eventProxyPort); 255 | eventProxyPort.postMessage.clear(); 256 | recorder.setScript('test'); 257 | assertScriptValue( 258 | devToolsPort, 259 | 'test', 260 | 'Verifier that the setScript function does send to the ' + 261 | 'devtools port when it is connected' 262 | ); 263 | assert.lengthOf( 264 | eventProxyPort.postMessage.calls, 265 | 0, 266 | 'Event proxy port should not be sent messages intended ' + 267 | 'for dev tools port' 268 | ); 269 | eventProxyPort.onMessage.emit({ 270 | method: 'recordEvent', 271 | args: [{ type: 'test' }] 272 | }); 273 | assert.deepEqual( 274 | recordEvent.calls, 275 | [[{ type: 'test' }]], 276 | 'RPC from the event proxy should be applied to the recorder' 277 | ); 278 | eventProxyPort.onDisconnect.emit(); 279 | recorder.setScript('test2'); 280 | assertScriptValue( 281 | devToolsPort, 282 | 'test2', 283 | 'The devtools port should not be disconnected when the event proxy port disconnects' 284 | ); 285 | } 286 | }, 287 | 288 | '#clear'() { 289 | // TODO: Record some stuff first to verify everything is 290 | // actually being cleared and not just OK from a pristine 291 | // recorder state 292 | recorder.setTabId(1); 293 | recorder.clear(); 294 | assertScriptValue(devToolsPort, testData.blank); 295 | }, 296 | 297 | '#hotkeys'() { 298 | assert.deepEqual( 299 | recorder.hotkeys, 300 | recorder._getDefaultHotkeys(), 301 | 'When no hotkey data is in storage, use predefined defaults' 302 | ); 303 | const prepopulatedStorage = new MockStorage({ 304 | 'intern.hotkeys': '{"foo":"foo"}' 305 | }); 306 | const prepopulatedRecorder = new Recorder( 307 | chrome, 308 | prepopulatedStorage 309 | ); 310 | assert.deepEqual( 311 | prepopulatedRecorder.hotkeys, 312 | { foo: 'foo' }, 313 | 'When hotkey data is in storage, use data from storage' 314 | ); 315 | }, 316 | 317 | '#insertCallback'() { 318 | recorder.setTabId(1); 319 | 320 | const expected = getLastScriptValue(devToolsPort); 321 | recorder.insertCallback(); 322 | assert.strictEqual( 323 | getLastScriptValue(devToolsPort), 324 | expected, 325 | 'insertCallback should be a no-op if not recording' 326 | ); 327 | recorder.toggleState(); 328 | recorder.insertCallback(); 329 | 330 | recorder.setSuiteName('callback'); 331 | assertScriptValue(devToolsPort, testData.callback); 332 | }, 333 | 334 | '#insertMouseMove'() { 335 | recorder.setTabId(2); 336 | 337 | const expected1 = getLastScriptValue(devToolsPort); 338 | recorder.insertMouseMove(); 339 | assert.strictEqual( 340 | getLastScriptValue(devToolsPort), 341 | expected1, 342 | 'insertMouseMove should be a no-op if not recording' 343 | ); 344 | recorder.toggleState(); 345 | const expected2 = getLastScriptValue(devToolsPort); 346 | recorder.insertMouseMove(); 347 | assert.strictEqual( 348 | getLastScriptValue(devToolsPort), 349 | expected2, 350 | 'insertMouseMove should be a no-op if there was no previous mouse move' 351 | ); 352 | recorder.recordEvent( 353 | createEvent({ type: 'mousemove', target: 'id("b2")' }) 354 | ); 355 | recorder.insertMouseMove(); 356 | 357 | recorder.setSuiteName('mouseMove'); 358 | assertScriptValue(devToolsPort, testData.mouseMove); 359 | }, 360 | 361 | navigation() { 362 | recorder.setTabId(2); 363 | recorder.toggleState(); 364 | 365 | // to test target reset on navigation 366 | recorder.recordEvent( 367 | createEvent({ 368 | type: 'mousedown', 369 | buttons: 1, 370 | target: 'id("b2")' 371 | }) 372 | ); 373 | recorder.recordEvent(createEvent({ type: 'mouseup' })); 374 | recorder.recordEvent( 375 | createEvent({ 376 | type: 'click', 377 | target: 'id("b2")', 378 | elementX: 5, 379 | elementY: 11 380 | }) 381 | ); 382 | // should be ignored due to tab mismatch 383 | chrome.webNavigation.onCommitted.emit({ 384 | tabId: 1, 385 | frameId: 0, 386 | transitionType: 'reload', 387 | transitionQualifiers: ['from_address_bar'], 388 | url: testPage 389 | }); 390 | chrome.webNavigation.onCommitted.emit({ 391 | tabId: 2, 392 | frameId: 0, 393 | transitionType: 'reload', 394 | transitionQualifiers: ['from_address_bar'], 395 | url: testPage 396 | }); 397 | // should be ignored due to frameId mismatch 398 | chrome.webNavigation.onCommitted.emit({ 399 | tabId: 1, 400 | frameId: 1, 401 | transitionType: 'reload', 402 | transitionQualifiers: ['from_address_bar'], 403 | url: testPage 404 | }); 405 | // should be ignored due to transitionType/transitionQualifiers mismatch 406 | chrome.webNavigation.onReferenceFragmentUpdated.emit({ 407 | tabId: 1, 408 | frameId: 0, 409 | transitionType: 'link', 410 | transitionQualifiers: [], 411 | url: `${testPage}#test` 412 | }); 413 | chrome.webNavigation.onCommitted.emit({ 414 | tabId: 2, 415 | frameId: 0, 416 | transitionType: 'link', 417 | transitionQualifiers: ['from_address_bar'], 418 | url: `${testPage}` 419 | }); 420 | chrome.webNavigation.onCommitted.emit({ 421 | tabId: 2, 422 | frameId: 0, 423 | transitionType: 'link', 424 | transitionQualifiers: ['from_address_bar'], 425 | url: `${testPage}#test` 426 | }); 427 | chrome.webNavigation.onCommitted.emit({ 428 | tabId: 2, 429 | frameId: 0, 430 | transitionType: 'link', 431 | transitionQualifiers: ['from_address_bar'], 432 | url: `${testHost}/elements.html` 433 | }); 434 | chrome.webNavigation.onCommitted.emit({ 435 | tabId: 1, 436 | frameId: 0, 437 | transitionType: 'typed', 438 | transitionQualifiers: ['forward_back', 'from_address_bar'], 439 | url: testPage 440 | }); 441 | chrome.webNavigation.onReferenceFragmentUpdated.emit({ 442 | tabId: 1, 443 | frameId: 0, 444 | transitionType: 'link', 445 | transitionQualifiers: ['forward_back'], 446 | url: `${testPage}#test` 447 | }); 448 | chrome.webNavigation.onHistoryStateUpdated.emit({ 449 | tabId: 1, 450 | frameId: 0, 451 | transitionType: 'auto_subframe', 452 | transitionQualifiers: [], 453 | url: testPage 454 | }); 455 | chrome.webNavigation.onCommitted.emit({ 456 | tabId: 1, 457 | frameId: 0, 458 | transitionType: 'typed', 459 | transitionQualifiers: ['from_address_bar'], 460 | url: 'http://localhost:9000/elements.html' 461 | }); 462 | 463 | recorder.setSuiteName('navigation'); 464 | assertScriptValue(devToolsPort, testData.navigation); 465 | }, 466 | 467 | '#newTest': { 468 | 'missing tabId'() { 469 | assert.throws(function() { 470 | recorder.newTest(); 471 | }, 'missing tabId'); 472 | }, 473 | 474 | 'multiple tests'() { 475 | recorder.setTabId(1); 476 | 477 | recorder.toggleState(); 478 | recorder.insertCallback(); 479 | recorder.newTest(); 480 | recorder.recordEvent( 481 | createEvent({ type: 'mousemove', targetFrame: [0] }) 482 | ); 483 | recorder.recordEvent( 484 | createEvent({ 485 | type: 'mousedown', 486 | targetFrame: [0], 487 | buttons: 1, 488 | target: 'id("b2")' 489 | }) 490 | ); 491 | recorder.recordEvent( 492 | createEvent({ type: 'mouseup', targetFrame: [0] }) 493 | ); 494 | recorder.recordEvent( 495 | createEvent({ 496 | type: 'click', 497 | targetFrame: [0], 498 | elementX: 12, 499 | elementY: 23 500 | }) 501 | ); 502 | recorder.newTest(); 503 | recorder.insertCallback(); 504 | 505 | recorder.setSuiteName('newTest'); 506 | assertScriptValue(devToolsPort, testData.newTest); 507 | } 508 | }, 509 | 510 | '#recordEvent': { 511 | 'not recording'() { 512 | recorder.setTabId(1); 513 | assert.isFalse(recorder.recording); 514 | recorder.recordEvent(createEvent({ type: 'mousemove' })); 515 | assertScriptValue(devToolsPort, testData.blank); 516 | }, 517 | 518 | click() { 519 | recorder.setTabId(2); 520 | recorder.toggleState(); 521 | 522 | recorder.recordEvent(createEvent({ type: 'mousemove' })); 523 | recorder.recordEvent( 524 | createEvent({ 525 | type: 'mousedown', 526 | buttons: 1, 527 | target: 'id("b2")' 528 | }) 529 | ); 530 | recorder.recordEvent( 531 | createEvent({ 532 | type: 'mouseup', 533 | target: '/HTML/BODY[1]' 534 | }) 535 | ); 536 | recorder.recordEvent(createEvent({ type: 'click' })); 537 | recorder.setSuiteName('click'); 538 | assertScriptValue(devToolsPort, testData.click); 539 | }, 540 | 541 | 'double click'() { 542 | recorder.setTabId(2); 543 | recorder.toggleState(); 544 | 545 | recorder.recordEvent(createEvent({ type: 'mousemove' })); 546 | recorder.recordEvent( 547 | createEvent({ 548 | type: 'mousedown', 549 | buttons: 1, 550 | target: 'id("b2")' 551 | }) 552 | ); 553 | recorder.recordEvent( 554 | createEvent({ 555 | type: 'mouseup', 556 | target: '/HTML/BODY[1]' 557 | }) 558 | ); 559 | recorder.recordEvent(createEvent({ type: 'click' })); 560 | recorder.recordEvent( 561 | createEvent({ type: 'mousedown', buttons: 1 }) 562 | ); 563 | recorder.recordEvent( 564 | createEvent({ 565 | type: 'mouseup', 566 | target: '/HTML/BODY[1]' 567 | }) 568 | ); 569 | recorder.recordEvent(createEvent({ type: 'click' })); 570 | recorder.recordEvent(createEvent({ type: 'dblclick' })); 571 | recorder.setSuiteName('doubleClick'); 572 | assertScriptValue(devToolsPort, testData.doubleClick); 573 | }, 574 | 575 | drag() { 576 | recorder.setTabId(2); 577 | recorder.toggleState(); 578 | 579 | recorder.recordEvent( 580 | createEvent({ 581 | type: 'mousedown', 582 | elementX: 9, 583 | elementY: 9, 584 | buttons: 1, 585 | target: 'id("b2")' 586 | }) 587 | ); 588 | recorder.recordEvent( 589 | createEvent({ 590 | type: 'mousemove', 591 | elementX: 10, 592 | elementY: 9, 593 | buttons: 1, 594 | target: 'id("b2")' 595 | }) 596 | ); 597 | recorder.recordEvent( 598 | createEvent({ 599 | type: 'mouseup', 600 | target: '/HTML/BODY[1]', 601 | elementX: 32, 602 | elementY: 43 603 | }) 604 | ); 605 | recorder.setSuiteName('drag'); 606 | assertScriptValue(devToolsPort, testData.drag); 607 | }, 608 | 609 | frame() { 610 | recorder.setTabId(3); 611 | recorder.toggleState(); 612 | 613 | recorder.recordEvent( 614 | createEvent({ 615 | type: 'mousemove', 616 | targetFrame: [1, 0] 617 | }) 618 | ); 619 | recorder.recordEvent( 620 | createEvent({ 621 | type: 'mousedown', 622 | targetFrame: [1, 0], 623 | buttons: 1, 624 | target: 'id("b2")' 625 | }) 626 | ); 627 | recorder.recordEvent( 628 | createEvent({ 629 | type: 'mouseup', 630 | targetFrame: [1, 0], 631 | target: '/HTML/BODY[1]' 632 | }) 633 | ); 634 | recorder.recordEvent( 635 | createEvent({ 636 | type: 'click', 637 | targetFrame: [1, 0], 638 | elementX: 8, 639 | elementY: 11 640 | }) 641 | ); 642 | recorder.recordEvent( 643 | createEvent({ 644 | type: 'mousemove', 645 | targetFrame: [1, 1] 646 | }) 647 | ); 648 | recorder.recordEvent( 649 | createEvent({ 650 | type: 'mousedown', 651 | targetFrame: [1, 1], 652 | buttons: 1, 653 | target: '/HTML/BODY[1]/P' 654 | }) 655 | ); 656 | recorder.recordEvent( 657 | createEvent({ 658 | type: 'mouseup', 659 | targetFrame: [1, 1], 660 | target: '/HTML/BODY[1]/P' 661 | }) 662 | ); 663 | recorder.recordEvent( 664 | createEvent({ 665 | type: 'click', 666 | targetFrame: [1, 1], 667 | elementX: 22, 668 | elementY: 27 669 | }) 670 | ); 671 | recorder.setSuiteName('frame'); 672 | assertScriptValue(devToolsPort, testData.frame); 673 | }, 674 | 675 | type() { 676 | recorder.setTabId(1); 677 | recorder.toggleState(); 678 | 679 | // H 680 | recorder.recordEvent( 681 | createEvent({ 682 | type: 'keydown', 683 | key: 'Shift', 684 | shiftKey: true 685 | }) 686 | ); 687 | recorder.recordEvent( 688 | createEvent({ 689 | type: 'keydown', 690 | key: 'U+0048', 691 | shiftKey: true 692 | }) 693 | ); 694 | recorder.recordEvent( 695 | createEvent({ 696 | type: 'keyup', 697 | key: 'U+0048', 698 | shiftKey: true 699 | }) 700 | ); 701 | recorder.recordEvent( 702 | createEvent({ type: 'keyup', key: 'Shift' }) 703 | ); 704 | // e 705 | recorder.recordEvent( 706 | createEvent({ type: 'keydown', key: 'U+0045' }) 707 | ); 708 | recorder.recordEvent( 709 | createEvent({ type: 'keyup', key: 'U+0045' }) 710 | ); 711 | // l 712 | recorder.recordEvent( 713 | createEvent({ type: 'keydown', key: 'U+004C' }) 714 | ); 715 | recorder.recordEvent( 716 | createEvent({ type: 'keyup', key: 'U+004C' }) 717 | ); 718 | // l 719 | recorder.recordEvent( 720 | createEvent({ type: 'keydown', key: 'U+004C' }) 721 | ); 722 | recorder.recordEvent( 723 | createEvent({ type: 'keyup', key: 'U+004C' }) 724 | ); 725 | // o 726 | recorder.recordEvent( 727 | createEvent({ type: 'keydown', key: 'U+004F' }) 728 | ); 729 | recorder.recordEvent( 730 | createEvent({ type: 'keyup', key: 'U+004F' }) 731 | ); 732 | // , 733 | recorder.recordEvent( 734 | createEvent({ type: 'keydown', key: 'U+002C' }) 735 | ); 736 | recorder.recordEvent( 737 | createEvent({ type: 'keyup', key: 'U+002C' }) 738 | ); 739 | // 740 | recorder.recordEvent( 741 | createEvent({ type: 'keydown', key: 'U+0020' }) 742 | ); 743 | recorder.recordEvent( 744 | createEvent({ type: 'keyup', key: 'U+0020' }) 745 | ); 746 | // w 747 | recorder.recordEvent( 748 | createEvent({ type: 'keydown', key: 'U+0057' }) 749 | ); 750 | recorder.recordEvent( 751 | createEvent({ type: 'keyup', key: 'U+0057' }) 752 | ); 753 | // o 754 | recorder.recordEvent( 755 | createEvent({ type: 'keydown', key: 'U+004F' }) 756 | ); 757 | recorder.recordEvent( 758 | createEvent({ type: 'keyup', key: 'U+004F' }) 759 | ); 760 | // r 761 | recorder.recordEvent( 762 | createEvent({ type: 'keydown', key: 'U+0052' }) 763 | ); 764 | recorder.recordEvent( 765 | createEvent({ type: 'keyup', key: 'U+0052' }) 766 | ); 767 | // l 768 | recorder.recordEvent( 769 | createEvent({ type: 'keydown', key: 'U+004C' }) 770 | ); 771 | recorder.recordEvent( 772 | createEvent({ type: 'keyup', key: 'U+004C' }) 773 | ); 774 | // d 775 | recorder.recordEvent( 776 | createEvent({ type: 'keydown', key: 'U+0044' }) 777 | ); 778 | recorder.recordEvent( 779 | createEvent({ type: 'keyup', key: 'U+0044' }) 780 | ); 781 | // ! 782 | recorder.recordEvent( 783 | createEvent({ 784 | type: 'keydown', 785 | key: 'Shift', 786 | shiftKey: true 787 | }) 788 | ); 789 | recorder.recordEvent( 790 | createEvent({ 791 | type: 'keydown', 792 | key: 'U+0021', 793 | shiftKey: true 794 | }) 795 | ); 796 | recorder.recordEvent( 797 | createEvent({ 798 | type: 'keyup', 799 | key: 'U+0021', 800 | shiftKey: true 801 | }) 802 | ); 803 | recorder.recordEvent( 804 | createEvent({ type: 'keyup', key: 'Shift' }) 805 | ); 806 | // Shift/unshift test 807 | recorder.recordEvent( 808 | createEvent({ 809 | type: 'keydown', 810 | key: 'Shift', 811 | shiftKey: true 812 | }) 813 | ); 814 | recorder.recordEvent( 815 | createEvent({ type: 'keyup', key: 'Shift' }) 816 | ); 817 | // keypad 0 test 818 | recorder.recordEvent( 819 | createEvent({ 820 | type: 'keydown', 821 | key: 'U+0030', 822 | location: 3 823 | }) 824 | ); 825 | recorder.recordEvent( 826 | createEvent({ 827 | type: 'keyup', 828 | key: 'U+0030', 829 | location: 3 830 | }) 831 | ); 832 | // non-printable character test 833 | recorder.recordEvent( 834 | createEvent({ type: 'keydown', key: 'Enter' }) 835 | ); 836 | recorder.recordEvent( 837 | createEvent({ type: 'keyup', key: 'Enter' }) 838 | ); 839 | recorder.setSuiteName('type'); 840 | assertScriptValue(devToolsPort, testData.type); 841 | }, 842 | 843 | hotkey: { 844 | beforeEach() { 845 | recorder.setTabId(1); 846 | recorder.toggleState(); 847 | }, 848 | 849 | tests: { 850 | 'with other keypresses'() { 851 | const { method: insertCallback } = mock( 852 | recorder, 853 | 'insertCallback' 854 | ); 855 | recorder.setHotkey('insertCallback', { 856 | key: 'U+002B', 857 | ctrlKey: true 858 | }); 859 | recorder.recordEvent( 860 | createEvent({ 861 | type: 'keydown', 862 | key: 'Control', 863 | ctrlKey: true 864 | }) 865 | ); 866 | recorder.recordEvent( 867 | createEvent({ 868 | type: 'keyup', 869 | key: 'Control', 870 | ctrlKey: false 871 | }) 872 | ); 873 | assert.lengthOf( 874 | insertCallback.calls, 875 | 0, 876 | 'Pressing only one part of a hotkey combination should not cause the hotkey to activate' 877 | ); 878 | recorder.recordEvent( 879 | createEvent({ 880 | type: 'keydown', 881 | key: 'Control', 882 | ctrlKey: true 883 | }) 884 | ); 885 | recorder.recordEvent( 886 | createEvent({ 887 | type: 'keydown', 888 | key: 'U+002B', 889 | ctrlKey: true 890 | }) 891 | ); 892 | recorder.recordEvent( 893 | createEvent({ 894 | type: 'keyup', 895 | key: 'U+002B', 896 | ctrlKey: true 897 | }) 898 | ); 899 | recorder.recordEvent( 900 | createEvent({ 901 | type: 'keyup', 902 | key: 'Control', 903 | ctrlKey: false 904 | }) 905 | ); 906 | assert.lengthOf( 907 | insertCallback.calls, 908 | 1, 909 | 'Pressing a hotkey should cause the corresponding hotkey to activate' 910 | ); 911 | recorder.setSuiteName('hotkey'); 912 | assertScriptValue(devToolsPort, testData.hotkey); 913 | }, 914 | 915 | 'with no other keypresses'() { 916 | const { method: insertCallback } = mock( 917 | recorder, 918 | 'insertCallback' 919 | ); 920 | recorder.setHotkey('insertCallback', { 921 | key: 'U+002B', 922 | ctrlKey: true 923 | }); 924 | recorder.recordEvent( 925 | createEvent({ 926 | type: 'keydown', 927 | key: 'Control', 928 | ctrlKey: true 929 | }) 930 | ); 931 | recorder.recordEvent( 932 | createEvent({ 933 | type: 'keydown', 934 | key: 'U+002B', 935 | ctrlKey: true 936 | }) 937 | ); 938 | recorder.recordEvent( 939 | createEvent({ 940 | type: 'keyup', 941 | key: 'U+002B', 942 | ctrlKey: true 943 | }) 944 | ); 945 | recorder.recordEvent( 946 | createEvent({ 947 | type: 'keyup', 948 | key: 'Control', 949 | ctrlKey: false 950 | }) 951 | ); 952 | assert.lengthOf( 953 | insertCallback.calls, 954 | 1, 955 | 'Pressing a hotkey should cause the corresponding hotkey to activate' 956 | ); 957 | assertScriptValue(devToolsPort, testData.blank); 958 | }, 959 | 960 | 'modifier-free hotkeys'() { 961 | const { method: insertCallback } = mock( 962 | recorder, 963 | 'insertCallback' 964 | ); 965 | recorder.setHotkey('insertCallback', { 966 | key: 'Home' 967 | }); 968 | recorder.recordEvent( 969 | createEvent({ 970 | type: 'keydown', 971 | key: 'Control', 972 | ctrlKey: true 973 | }) 974 | ); 975 | recorder.recordEvent( 976 | createEvent({ 977 | type: 'keydown', 978 | key: 'Home', 979 | ctrlKey: true 980 | }) 981 | ); 982 | recorder.recordEvent( 983 | createEvent({ 984 | type: 'keyup', 985 | key: 'Home', 986 | ctrlKey: true 987 | }) 988 | ); 989 | recorder.recordEvent( 990 | createEvent({ 991 | type: 'keyup', 992 | key: 'Control', 993 | ctrlKey: false 994 | }) 995 | ); 996 | assert.lengthOf( 997 | insertCallback.calls, 998 | 0, 999 | 'Pressing a hotkey with other modifiers active should not cause the hotkey to activate' 1000 | ); 1001 | recorder.recordEvent( 1002 | createEvent({ 1003 | type: 'keydown', 1004 | key: 'Home' 1005 | }) 1006 | ); 1007 | recorder.recordEvent( 1008 | createEvent({ type: 'keyup', key: 'Home' }) 1009 | ); 1010 | assert.lengthOf( 1011 | insertCallback.calls, 1012 | 1, 1013 | 'Pressing a hotkey with other modifiers active should not cause the hotkey to activate' 1014 | ); 1015 | }, 1016 | 1017 | 'when recording is off': { 1018 | toggleState() { 1019 | recorder.toggleState(); 1020 | assert.isFalse(recorder.recording); 1021 | recorder.setHotkey('toggleState', { 1022 | key: 'U+002B', 1023 | ctrlKey: true 1024 | }); 1025 | recorder.recordEvent( 1026 | createEvent({ 1027 | type: 'keydown', 1028 | key: 'Control', 1029 | ctrlKey: true 1030 | }) 1031 | ); 1032 | recorder.recordEvent( 1033 | createEvent({ 1034 | type: 'keydown', 1035 | key: 'U+002B', 1036 | ctrlKey: true 1037 | }) 1038 | ); 1039 | recorder.recordEvent( 1040 | createEvent({ 1041 | type: 'keyup', 1042 | key: 'U+002B', 1043 | ctrlKey: true 1044 | }) 1045 | ); 1046 | recorder.recordEvent( 1047 | createEvent({ 1048 | type: 'keyup', 1049 | key: 'Control', 1050 | ctrlKey: false 1051 | }) 1052 | ); 1053 | assert.isTrue( 1054 | recorder.recording, 1055 | 'toggleState hotkey should work even if recording is off' 1056 | ); 1057 | }, 1058 | 1059 | others() { 1060 | recorder.toggleState(); 1061 | assert.isFalse(recorder.recording); 1062 | const { method: insertCallback } = mock( 1063 | recorder, 1064 | 'insertCallback' 1065 | ); 1066 | recorder.setHotkey('insertCallback', { 1067 | key: 'U+002B', 1068 | ctrlKey: true 1069 | }); 1070 | recorder.recordEvent( 1071 | createEvent({ 1072 | type: 'keydown', 1073 | key: 'Control', 1074 | ctrlKey: true 1075 | }) 1076 | ); 1077 | recorder.recordEvent( 1078 | createEvent({ 1079 | type: 'keydown', 1080 | key: 'U+002B', 1081 | ctrlKey: true 1082 | }) 1083 | ); 1084 | recorder.recordEvent( 1085 | createEvent({ 1086 | type: 'keyup', 1087 | key: 'U+002B', 1088 | ctrlKey: true 1089 | }) 1090 | ); 1091 | recorder.recordEvent( 1092 | createEvent({ 1093 | type: 'keyup', 1094 | key: 'Control', 1095 | ctrlKey: false 1096 | }) 1097 | ); 1098 | assert.lengthOf( 1099 | insertCallback.calls, 1100 | 0, 1101 | 'other hotkeys should not do anything when recording is off' 1102 | ); 1103 | } 1104 | } 1105 | } 1106 | } 1107 | }, 1108 | 1109 | '#save'() { 1110 | const { URL, remove } = mockBlobAndUrl(); 1111 | recorder.setTabId(1); 1112 | recorder.setScript(testData.blank); 1113 | try { 1114 | recorder.save(); 1115 | assert.lengthOf(chrome.downloads.download.calls, 1); 1116 | assert.deepEqual(chrome.downloads.download.calls[0][0], { 1117 | filename: 'test.js', 1118 | saveAs: true, 1119 | url: 'blob://test' 1120 | }); 1121 | assert.deepEqual(URL.blob, { 1122 | data: testData.blank, 1123 | type: 'application/ecmascript' 1124 | }); 1125 | chrome.downloads.download.calls[0][1](); 1126 | assert.isNull( 1127 | URL.blob, 1128 | 'The download callback should revoke the object URL' 1129 | ); 1130 | } finally { 1131 | remove(); 1132 | } 1133 | }, 1134 | 1135 | '#setCustomAttribute'() { 1136 | devToolsPort.postMessage.clear(); 1137 | const eventProxyPort = chrome.createPort('eventProxy'); 1138 | chrome.runtime.onConnect.emit(eventProxyPort); 1139 | eventProxyPort.postMessage.clear(); 1140 | recorder.setCustomAttribute('foo'); 1141 | assert.lengthOf(devToolsPort.postMessage.calls, 0); 1142 | assert.deepEqual(eventProxyPort.postMessage.calls, [ 1143 | [{ method: 'setCustomAttribute', args: ['foo'] }] 1144 | ]); 1145 | }, 1146 | 1147 | '#setFindDisplayed'() { 1148 | devToolsPort.postMessage.clear(); 1149 | const eventProxyPort = chrome.createPort('eventProxy'); 1150 | chrome.runtime.onConnect.emit(eventProxyPort); 1151 | eventProxyPort.postMessage.clear(); 1152 | 1153 | recorder.setTabId(2); 1154 | recorder.toggleState(); 1155 | 1156 | recorder.setFindDisplayed(true); 1157 | 1158 | // Expect 4 calls: 1159 | // setScript for initial suite creation 1160 | // setScript for initial get 1161 | // setScript for setSuiteName (from URL in initial get) 1162 | // setRecording to enable recording 1163 | assert.lengthOf(devToolsPort.postMessage.calls, 4); 1164 | recorder.recordEvent( 1165 | createEvent({ 1166 | type: 'mousemove', 1167 | elementX: 12, 1168 | elementY: 23, 1169 | target: 'id("b2")' 1170 | }) 1171 | ); 1172 | recorder.insertMouseMove(); 1173 | recorder.setSuiteName('findDisplayed'); 1174 | assertScriptValue( 1175 | devToolsPort, 1176 | testData.findDisplayed, 1177 | 'Script should use "findDisplayedByXpath"' 1178 | ); 1179 | 1180 | recorder.clear(); 1181 | recorder.setFindDisplayed(false); 1182 | recorder.recordEvent( 1183 | createEvent({ type: 'mousemove', target: 'id("b2")' }) 1184 | ); 1185 | recorder.insertMouseMove(); 1186 | recorder.setSuiteName('mouseMove'); 1187 | assertScriptValue( 1188 | devToolsPort, 1189 | testData.mouseMove, 1190 | 'Script should use "findByXpath"' 1191 | ); 1192 | }, 1193 | 1194 | '#setHotkey'() { 1195 | const expected = { key: 'Foo' }; 1196 | devToolsPort.postMessage.clear(); 1197 | recorder.setHotkey('insertCallback', expected); 1198 | assert.deepEqual(recorder.hotkeys.insertCallback, expected); 1199 | assert.deepEqual(devToolsPort.postMessage.calls, [ 1200 | [ 1201 | { 1202 | method: 'setHotkey', 1203 | args: ['insertCallback', expected] 1204 | } 1205 | ] 1206 | ]); 1207 | const data = storage.getItem('intern.hotkeys')!; 1208 | assert.isString(data); 1209 | const hotkeys = JSON.parse(data); 1210 | assert.deepEqual(hotkeys.insertCallback, expected); 1211 | }, 1212 | 1213 | '#setScript'() { 1214 | devToolsPort.postMessage.clear(); 1215 | recorder.setScript('test'); 1216 | assert.deepEqual(devToolsPort.postMessage.calls, [ 1217 | [{ method: 'setScript', args: ['test'] }] 1218 | ]); 1219 | }, 1220 | 1221 | '#setStrategy'() { 1222 | devToolsPort.postMessage.clear(); 1223 | const eventProxyPort = chrome.createPort('eventProxy'); 1224 | chrome.runtime.onConnect.emit(eventProxyPort); 1225 | eventProxyPort.postMessage.clear(); 1226 | recorder.setStrategy('text'); 1227 | assert.lengthOf(devToolsPort.postMessage.calls, 0); 1228 | assert.deepEqual(eventProxyPort.postMessage.calls, [ 1229 | [{ method: 'setStrategy', args: ['text'] }] 1230 | ]); 1231 | assert.throws(function() { 1232 | // Use 'any' here to get TS to let us pass an invalid 1233 | // strategy 1234 | recorder.setStrategy('invalid'); 1235 | }, 'Invalid search strategy'); 1236 | }, 1237 | 1238 | '#setTabId'() { 1239 | assert.isNull(recorder.tabId); 1240 | recorder.setTabId(1); 1241 | assert.strictEqual(recorder.tabId, 1); 1242 | // Use 'any' here to get TS to let us pass an invalid 1243 | // tab ID 1244 | recorder.setTabId(null); 1245 | assert.strictEqual( 1246 | recorder.tabId, 1247 | 1, 1248 | 'null tab IDs should be ignored' 1249 | ); 1250 | }, 1251 | 1252 | '#toggleState': { 1253 | 'missing tabId': function() { 1254 | assert.throws(function() { 1255 | recorder.toggleState(); 1256 | }, 'missing tabId'); 1257 | }, 1258 | 1259 | toggle: function() { 1260 | const { method: newTest } = mock(recorder, 'newTest', true); 1261 | 1262 | recorder.setTabId(1); 1263 | assert.isFalse(recorder.recording); 1264 | 1265 | recorder.toggleState(); 1266 | assert.isTrue(recorder.recording); 1267 | assert.deepEqual( 1268 | chrome.tabs.executeScript.calls, 1269 | [[1, { file: 'lib/content.js', allFrames: true }]], 1270 | 'Content scripts should be injected when turning ' + 1271 | 'on recording' 1272 | ); 1273 | assert.deepEqual( 1274 | newTest.calls, 1275 | [[]], 1276 | 'New test should automatically be created when ' + 1277 | 'toggling recording for the first time' 1278 | ); 1279 | 1280 | chrome.tabs.executeScript.clear(); 1281 | recorder.toggleState(); 1282 | assert.isFalse(recorder.recording); 1283 | assert.lengthOf( 1284 | chrome.tabs.executeScript.calls, 1285 | 0, 1286 | 'Content scripts should not be injected when ' + 1287 | 'turning off recording' 1288 | ); 1289 | recorder.toggleState(); 1290 | assert.isTrue(recorder.recording); 1291 | assert.deepEqual( 1292 | newTest.calls, 1293 | [[]], 1294 | 'New test should not automatically be created when toggling recording a second time' 1295 | ); 1296 | } 1297 | } 1298 | } 1299 | }; 1300 | }); 1301 | -------------------------------------------------------------------------------- /tests/unit/RecorderProxy.ts: -------------------------------------------------------------------------------- 1 | import { mock } from '../support/util'; 2 | import MockChrome, { Button, Panel, Port } from '../support/mockChromeApi'; 3 | import MockWindow, { Event } from '../support/mockDomApi'; 4 | import { createMockMethod, Method } from '../support/util'; 5 | import RecorderProxy from '../../src/RecorderProxy'; 6 | 7 | const { assert } = intern.getPlugin('chai'); 8 | const { registerSuite } = intern.getPlugin('interface.object'); 9 | 10 | const hotkeyIds = ['insertCallback', 'insertMouseMove', 'toggleState']; 11 | 12 | interface TestEvent extends Event { 13 | preventDefault: Method<() => void>; 14 | } 15 | 16 | function createEvent(event: { key: string; [key: string]: any }): TestEvent { 17 | if (!event || !event.key) { 18 | throw new Error( 19 | 'At least "key" is required to generate an event object' 20 | ); 21 | } 22 | 23 | return Object.assign( 24 | { 25 | type: 'keyboard', 26 | altKey: false, 27 | ctrlKey: false, 28 | metaKey: false, 29 | preventDefault: createMockMethod(), 30 | shiftKey: false 31 | }, 32 | event 33 | ); 34 | } 35 | 36 | registerSuite('RecorderProxy', () => { 37 | let chrome: MockChrome; 38 | let devToolsPort: Port; 39 | let recorderProxy: RecorderProxy; 40 | let panel: Panel; 41 | let window: MockWindow; 42 | 43 | return { 44 | beforeEach() { 45 | chrome = new MockChrome(); 46 | window = new MockWindow(''); 47 | panel = chrome.createPanel(); 48 | recorderProxy = new RecorderProxy(chrome, panel); 49 | panel.onShown.emit(window); 50 | 51 | // The recorderProxy's port will be a Port because it's coming from 52 | // the mock chrome 53 | devToolsPort = recorderProxy._port!; 54 | }, 55 | 56 | after() { 57 | chrome = window = recorderProxy = devToolsPort = null; 58 | }, 59 | 60 | tests: { 61 | 'port communication'() { 62 | // TODO: Chai needs a deepInclude 63 | assert.deepEqual( 64 | devToolsPort.postMessage.calls[0], 65 | [{ method: 'setTabId', args: [1692485] }], 66 | 'The proxy should send the currently inspected tab ID ' + 67 | 'to the recorder immediately upon creation' 68 | ); 69 | 70 | assert.throws(function() { 71 | devToolsPort.onMessage.emit({ method: 'not-a-method' }); 72 | }, 'Method "not-a-method" does not exist'); 73 | 74 | const { method: setHotkey } = mock(recorderProxy, 'setHotkey'); 75 | devToolsPort.onMessage.emit({ 76 | method: 'setHotkey', 77 | args: ['foo'] 78 | }); 79 | devToolsPort.onMessage.emit({ method: 'setHotkey' }); 80 | assert.deepEqual( 81 | setHotkey.calls, 82 | [['foo'], []], 83 | 'Valid calls from communication port should be executed on the proxy' 84 | ); 85 | }, 86 | 87 | 'listener setup': function() { 88 | hotkeyIds.forEach(function(id) { 89 | const input = window.document.getElementById( 90 | 'hotkey-' + id 91 | )!; 92 | assert.isFunction(input.onkeydown); 93 | }); 94 | 95 | const strategy = window.document.getElementById( 96 | 'option-strategy' 97 | )!; 98 | assert.isFunction(strategy.onchange); 99 | 100 | const findDisplayed = window.document.getElementById( 101 | 'option-findDisplayed' 102 | )!; 103 | assert.isFunction(findDisplayed.onchange); 104 | }, 105 | 106 | 'button communication': function() { 107 | const { method: send } = mock(recorderProxy, 'send'); 108 | 109 | panel.buttons.forEach(function(button) { 110 | button.onClicked.emit(); 111 | }); 112 | 113 | assert.deepEqual(send.calls, [ 114 | ['toggleState'], 115 | ['clear'], 116 | ['newTest'], 117 | ['save'] 118 | ]); 119 | }, 120 | 121 | 'hide and show': function() { 122 | const { method: send } = mock(recorderProxy, 'send'); 123 | 124 | panel.onHidden.emit(); 125 | panel.onShown.emit(window); 126 | assert.deepEqual(send.calls, [['refreshUi']]); 127 | 128 | recorderProxy.setRecording(true); 129 | assert.isTrue(recorderProxy.recording); 130 | 131 | send.clear(); 132 | panel.onHidden.emit(); 133 | assert.deepEqual(send.calls, [['toggleState']]); 134 | 135 | send.clear(); 136 | panel.onShown.emit(window); 137 | assert.deepEqual(send.calls, [['toggleState'], ['refreshUi']]); 138 | }, 139 | 140 | 'hotkey set': function() { 141 | const { method: send } = mock(recorderProxy, 'send'); 142 | 143 | hotkeyIds.forEach(function(id) { 144 | send.clear(); 145 | 146 | const input = window.document.getElementById( 147 | 'hotkey-' + id 148 | )!; 149 | assert.isFunction(input.onkeydown); 150 | 151 | const key = { 152 | altKey: false, 153 | ctrlKey: false, 154 | key: 'U+0045', 155 | metaKey: false, 156 | shiftKey: true 157 | }; 158 | 159 | const event = createEvent(key); 160 | input.onkeydown!(event); 161 | 162 | assert.lengthOf(event.preventDefault.calls, 1); 163 | 164 | assert.deepEqual(send.calls, [['setHotkey', [id, key]]]); 165 | }); 166 | }, 167 | 168 | 'script set': function() { 169 | recorderProxy.setScript('test'); 170 | const script = window.document.getElementById('script'); 171 | assert.deepEqual(script!.innerHTML, 'test'); 172 | }, 173 | 174 | 'strategy set': function() { 175 | const { method: send } = mock(recorderProxy, 'send'); 176 | 177 | recorderProxy.setScript('test'); 178 | 179 | window.document.getElementById('option-strategy')!.onchange!( 180 | { target: { value: 'test' } } 181 | ); 182 | assert.deepEqual(send.calls, [['setStrategy', ['test']]]); 183 | }, 184 | 185 | 'findDisplayed set': function() { 186 | const { method: send } = mock(recorderProxy, 'send'); 187 | 188 | recorderProxy.setScript('test'); 189 | 190 | window.document.getElementById('option-findDisplayed')! 191 | .onchange!({ target: { checked: true } }); 192 | assert.deepEqual(send.calls, [['setFindDisplayed', [true]]]); 193 | }, 194 | 195 | 'hidden panel': function() { 196 | const inactiveRecorderProxy = new RecorderProxy( 197 | chrome, 198 | panel 199 | ); 200 | assert.doesNotThrow(function() { 201 | inactiveRecorderProxy.setScript('test'); 202 | inactiveRecorderProxy.setStrategy('test'); 203 | inactiveRecorderProxy.setFindDisplayed(true); 204 | inactiveRecorderProxy.setHotkey('insertCallback', { 205 | key: 'U+0045' 206 | }); 207 | }, 'Setting properties for the UI without an active panel should be a no-op'); 208 | }, 209 | 210 | '#send': function() { 211 | devToolsPort.postMessage.clear(); 212 | recorderProxy.send('test', ['arg1', 'argN']); 213 | assert.deepEqual(devToolsPort.postMessage.calls, [ 214 | [{ method: 'test', args: ['arg1', 'argN'] }] 215 | ]); 216 | }, 217 | 218 | '#setFindDisplayed': function() { 219 | recorderProxy.setFindDisplayed(true); 220 | assert.strictEqual( 221 | window.document.getElementById('option-findDisplayed')! 222 | .checked, 223 | true 224 | ); 225 | }, 226 | 227 | '#setHotkey': { 228 | 'basic tests': function() { 229 | const testKeys = [ 230 | { 231 | id: 'insertCallback', 232 | key: { 233 | altKey: true, 234 | metaKey: true, 235 | ctrlKey: true, 236 | shiftKey: true, 237 | key: 'U+0045' 238 | }, 239 | others: 'Ctrl+Alt+Shift+Win+E', 240 | mac: '^⌥⇧⌘E' 241 | }, 242 | { 243 | id: 'insertMouseMove', 244 | key: { shiftKey: true, key: 'U+0021' }, 245 | others: '!', 246 | mac: '!' 247 | }, 248 | { 249 | id: 'toggleState', 250 | key: { ctrlKey: true, key: 'U+0009' }, 251 | others: 'Ctrl+Tab', 252 | mac: '^↹' 253 | }, 254 | { 255 | id: 'insertCallback', 256 | key: { shiftKey: true, key: 'Home' }, 257 | others: 'Shift+Home', 258 | mac: '⇧Home' 259 | }, 260 | { 261 | id: 'insertCallback', 262 | key: { shiftKey: true, key: 'Shift' }, 263 | others: 'Shift+', 264 | mac: '⇧' 265 | } 266 | ]; 267 | 268 | const macPanel = chrome.createPanel(); 269 | const macWindow = new MockWindow('MacIntel'); 270 | const macChrome = new MockChrome(); 271 | const macRecorderProxy = new RecorderProxy( 272 | macChrome, 273 | macPanel 274 | ); 275 | macPanel.onShown.emit(macWindow); 276 | 277 | testKeys.forEach(function(key) { 278 | recorderProxy.setHotkey(key.id, key.key); 279 | macRecorderProxy.setHotkey(key.id, key.key); 280 | assert.strictEqual( 281 | window.document.getElementById('hotkey-' + key.id)! 282 | .value, 283 | key.others 284 | ); 285 | assert.strictEqual( 286 | macWindow.document.getElementById( 287 | 'hotkey-' + key.id 288 | )!.value, 289 | key.mac 290 | ); 291 | }); 292 | 293 | assert.throws(function() { 294 | recorderProxy.setHotkey('invalid', {}); 295 | }, 'missing input for hotkey "invalid"'); 296 | }, 297 | 298 | 'crbug 48111': function() { 299 | recorderProxy.setHotkey('insertCallback', { 300 | key: 'U+00C0' 301 | }); 302 | assert.strictEqual( 303 | window.document.getElementById('hotkey-insertCallback')! 304 | .value, 305 | '`' 306 | ); 307 | recorderProxy.setHotkey('insertCallback', { 308 | shiftKey: true, 309 | key: 'U+00C0' 310 | }); 311 | assert.strictEqual( 312 | window.document.getElementById('hotkey-insertCallback')! 313 | .value, 314 | '~' 315 | ); 316 | } 317 | }, 318 | 319 | '#setRecording': function() { 320 | const recordButton: Button = recorderProxy._recordButton!; 321 | 322 | assert.isFalse(recorderProxy.recording); 323 | recorderProxy.setRecording(true); 324 | assert.isTrue(recorderProxy.recording); 325 | assert.deepEqual(recordButton.update.calls, [ 326 | ['resources/statusBarIcons/record_on.png'] 327 | ]); 328 | recordButton.update.clear(); 329 | recorderProxy.setRecording(false); 330 | assert.isFalse(recorderProxy.recording); 331 | assert.deepEqual(recordButton.update.calls, [ 332 | ['resources/statusBarIcons/record_off.png'] 333 | ]); 334 | }, 335 | 336 | '#setScript': function() { 337 | recorderProxy.setScript('test'); 338 | // Setting script to null should have no effect 339 | recorderProxy.setScript(null); 340 | assert.strictEqual( 341 | window.document.getElementById('script')!.innerHTML, 342 | 'test' 343 | ); 344 | }, 345 | 346 | '#setStrategy': function() { 347 | recorderProxy.setStrategy('xpath'); 348 | assert.strictEqual( 349 | window.document.getElementById('option-strategy')!.value, 350 | 'xpath' 351 | ); 352 | }, 353 | 354 | '#setCustomAttribute': function() { 355 | recorderProxy.setCustomAttribute('foo'); 356 | assert.strictEqual( 357 | window.document.getElementById('option-custom-attribute')! 358 | .value, 359 | 'foo' 360 | ); 361 | } 362 | } 363 | }; 364 | }); 365 | -------------------------------------------------------------------------------- /tests/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const { sync: glob } = require('glob'); 3 | 4 | module.exports = { 5 | entry: { 6 | tests: glob(join(__dirname, 'unit', '**', '*.ts')) 7 | }, 8 | output: { 9 | path: join(__dirname, 'build'), 10 | filename: '[name].js' 11 | }, 12 | devtool: 'source-map', 13 | module: { 14 | rules: [ 15 | { 16 | oneOf: [ 17 | { 18 | test: /tests\/integration\/.*\.ts$/, 19 | use: 'raw-loader' 20 | }, 21 | { 22 | test: /tests\/.*\.ts$/, 23 | use: [ 24 | { 25 | loader: 'ts-loader', 26 | options: { 27 | configFile: join(__dirname, 'tsconfig.json') 28 | } 29 | } 30 | ] 31 | }, 32 | { 33 | test: /\.ts$/, 34 | use: [ 35 | '@theintern/istanbul-loader', 36 | { 37 | loader: 'ts-loader', 38 | options: { 39 | configFile: join(__dirname, 'tsconfig.json') 40 | } 41 | } 42 | ] 43 | }, 44 | { 45 | test: /\.txt$/, 46 | use: 'raw-loader' 47 | } 48 | ] 49 | } 50 | ] 51 | }, 52 | resolve: { 53 | extensions: ['.ts', '.js'] 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@theintern/dev/tsconfig-base.json", 3 | "compilerOptions": { 4 | "importHelpers": true, 5 | "lib": ["es2015", "dom"], 6 | "module": "es2015", 7 | "target": "es2015", 8 | "outDir": "./build", 9 | "noErrorTruncation": true, 10 | "types": ["chrome"] 11 | }, 12 | "include": ["src/**/**.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { "extends": "@theintern/dev/tslint.json" } 2 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: { 6 | background: join(__dirname, 'src', 'background.ts'), 7 | content: join(__dirname, 'src', 'content.ts'), 8 | devtools: join(__dirname, 'src', 'devtools.ts'), 9 | Recorder: join(__dirname, 'src', 'Recorder.ts'), 10 | RecorderProxy: join(__dirname, 'src', 'RecorderProxy.ts') 11 | }, 12 | output: { 13 | path: join(__dirname, 'build'), 14 | filename: join('lib', '[name].js') 15 | }, 16 | devtool: 'source-map', 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | use: 'ts-loader' 22 | } 23 | ] 24 | }, 25 | resolve: { 26 | extensions: ['.ts', '.js'] 27 | }, 28 | plugins: [ 29 | // minify 30 | // new webpack.optimize.UglifyJsPlugin() 31 | new CopyWebpackPlugin( 32 | [ 33 | { from: 'lib', to: 'lib' }, 34 | { from: 'resources', to: 'resources' }, 35 | { from: 'lib', to: 'lib' }, 36 | { from: 'manifest.json' }, 37 | { from: 'LICENSE' }, 38 | { from: 'docs', to: 'docs' }, 39 | { 40 | from: require.resolve('highlight.js/styles/tomorrow.css'), 41 | to: 'lib/highlight.css' 42 | } 43 | ], 44 | { ignore: ['.DS_Store'] } 45 | ) 46 | ] 47 | }; 48 | --------------------------------------------------------------------------------