├── .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 |
5 |
6 |
7 |
8 | [](https://travis-ci.org/theintern/recorder)
9 | [](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 | 
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 | 
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 | Suite name
106 |
107 |
108 |
109 |
110 | Element selection strategy
111 |
112 | Use path of element
113 | Use text of element
114 |
115 |
116 |
117 |
118 | Custom attribute
119 |
120 |
121 |
122 |
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 |
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 & default
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 | back pack .
16 | What a cute, yellow backpack.
17 | What a cute, yellow
backpack.
18 |
19 | Make d in 250ms
20 | Destroy e in 250ms
21 |
22 |
23 | f
24 | g
25 |
26 |
27 |
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 |
--------------------------------------------------------------------------------