├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ ├── main.yml
│ └── publish-on-tag.yml
├── .gitignore
├── .npmignore
├── .npmrc
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── jest.config.js
├── package.json
├── prettier.config.js
├── src
├── cli.ts
├── helpers.ts
├── recorder.ts
└── runner.ts
├── test
├── getselector.spec.ts
├── helpers.spec.ts
├── public
│ ├── iframe1.html
│ ├── iframe2.html
│ ├── iframes.html
│ ├── index.html
│ └── page2.html
└── recorder.spec.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | lib/
2 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true,
5 | es6: true,
6 | },
7 |
8 | parser: '@typescript-eslint/parser',
9 |
10 | plugins: ['jest', '@typescript-eslint', 'unicorn', 'import'],
11 |
12 | extends: ['plugin:prettier/recommended'],
13 |
14 | rules: {
15 | // Error if files are not formatted with Prettier correctly.
16 | 'prettier/prettier': 2,
17 | // syntax preferences
18 | quotes: [
19 | 2,
20 | 'single',
21 | {
22 | avoidEscape: true,
23 | allowTemplateLiterals: true,
24 | },
25 | ],
26 | 'spaced-comment': [
27 | 2,
28 | 'always',
29 | {
30 | markers: ['*'],
31 | },
32 | ],
33 | eqeqeq: [2],
34 | 'accessor-pairs': [
35 | 2,
36 | {
37 | getWithoutSet: false,
38 | setWithoutGet: false,
39 | },
40 | ],
41 | 'new-parens': 2,
42 | 'func-call-spacing': 2,
43 | 'prefer-const': 2,
44 |
45 | 'max-len': [
46 | 2,
47 | {
48 | /* this setting doesn't impact things as we use Prettier to format
49 | * our code and hence dictate the line length.
50 | * Prettier aims for 80 but sometimes makes the decision to go just
51 | * over 80 chars as it decides that's better than wrapping. ESLint's
52 | * rule defaults to 80 but therefore conflicts with Prettier. So we
53 | * set it to something far higher than Prettier would allow to avoid
54 | * it causing issues and conflicting with Prettier.
55 | */
56 | code: 200,
57 | comments: 90,
58 | ignoreTemplateLiterals: true,
59 | ignoreUrls: true,
60 | ignoreStrings: true,
61 | ignoreRegExpLiterals: true,
62 | },
63 | ],
64 | // anti-patterns
65 | 'no-var': 2,
66 | 'no-with': 2,
67 | 'no-multi-str': 2,
68 | 'no-caller': 2,
69 | 'no-implied-eval': 2,
70 | 'no-labels': 2,
71 | 'no-new-object': 2,
72 | 'no-octal-escape': 2,
73 | 'no-self-compare': 2,
74 | 'no-shadow-restricted-names': 2,
75 | 'no-cond-assign': 2,
76 | 'no-debugger': 2,
77 | 'no-dupe-keys': 2,
78 | 'no-duplicate-case': 2,
79 | 'no-empty-character-class': 2,
80 | 'no-unreachable': 2,
81 | 'no-unsafe-negation': 2,
82 | radix: 2,
83 | 'valid-typeof': 2,
84 | 'no-unused-vars': [
85 | 1,
86 | {
87 | args: 'none',
88 | vars: 'local',
89 | varsIgnorePattern:
90 | '([fx]?describe|[fx]?it|beforeAll|beforeEach|afterAll|afterEach)',
91 | },
92 | ],
93 | 'no-implicit-globals': [2],
94 |
95 | // es2015 features
96 | 'require-yield': 2,
97 | 'template-curly-spacing': [2, 'never'],
98 |
99 | // ensure we don't have any it.only or describe.only in prod
100 | 'jest/no-focused-tests': 'error',
101 |
102 | // enforce the variable in a catch block is named error
103 | 'unicorn/catch-error-name': 'error',
104 |
105 | 'no-restricted-imports': [
106 | 'error',
107 | {
108 | patterns: ['*Events'],
109 | paths: [
110 | {
111 | name: 'mitt',
112 | message:
113 | 'Import Mitt from the vendored location: vendor/mitt/src/index.js',
114 | },
115 | ],
116 | },
117 | ],
118 | // 'import/extensions': ['error', 'ignorePackages'],
119 | },
120 | overrides: [
121 | {
122 | files: ['*.ts'],
123 | extends: [
124 | 'plugin:@typescript-eslint/eslint-recommended',
125 | 'plugin:@typescript-eslint/recommended',
126 | ],
127 | rules: {
128 | 'no-unused-vars': 0,
129 | '@typescript-eslint/no-unused-vars': 1,
130 | 'func-call-spacing': 0,
131 | '@typescript-eslint/func-call-spacing': 2,
132 | semi: 0,
133 | '@typescript-eslint/semi': 2,
134 | '@typescript-eslint/no-empty-function': 0,
135 | '@typescript-eslint/no-use-before-define': 0,
136 | // We have to use any on some types so the warning isn't valuable.
137 | '@typescript-eslint/no-explicit-any': 0,
138 | // We don't require explicit return types on basic functions or
139 | // dummy functions in tests, for example
140 | '@typescript-eslint/explicit-function-return-type': 0,
141 | // We know it's bad and use it very sparingly but it's needed :(
142 | '@typescript-eslint/ban-ts-ignore': 0,
143 | '@typescript-eslint/ban-ts-comment': 0,
144 | /**
145 | * This is the default options (as per
146 | * https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/ban-types.md),
147 | *
148 | * Unfortunately there's no way to
149 | */
150 | '@typescript-eslint/ban-types': [
151 | 'error',
152 | {
153 | extendDefaults: true,
154 | types: {
155 | /*
156 | * Puppeteer's API accepts generic functions in many places so it's
157 | * not a useful linting rule to ban the `Function` type. This turns off
158 | * the banning of the `Function` type which is a default rule.
159 | */
160 | Function: false,
161 | },
162 | },
163 | ],
164 | '@typescript-eslint/array-type': [
165 | 2,
166 | {
167 | default: 'array-simple',
168 | },
169 | ],
170 | },
171 | },
172 | {
173 | files: ['test-browser/**/*.js'],
174 | parserOptions: {
175 | sourceType: 'module',
176 | },
177 | env: {
178 | es6: true,
179 | browser: true,
180 | es2020: true,
181 | },
182 | },
183 | ],
184 | };
185 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Run tests # Give it any name
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | run-tests:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v2
14 | - name: Build
15 | run: npm install && npm run build
16 | - name: Test
17 | run: npm test
18 |
--------------------------------------------------------------------------------
/.github/workflows/publish-on-tag.yml:
--------------------------------------------------------------------------------
1 | name: publish-on-tag
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v2
14 | - name: Install dependencies
15 | run: npm install
16 | - name: Build
17 | run: npm run build
18 | - name: Publish
19 | env:
20 | NPM_TOKEN: ${{secrets.NPM_TOKEN}}
21 | run: |
22 | npm config set '//wombat-dressing-room.appspot.com/:_authToken' '${NPM_TOKEN}'
23 | npm publish
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | lib
4 | .DS_store
5 | package-lock.json
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .github/
2 | coverage/
3 | jest.config.js
4 | src/
5 | test/
6 | tsconfig.json
7 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://wombat-dressing-room.appspot.com/
2 | access=public
3 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Contributor License Agreement
7 |
8 | Contributions to this project must be accompanied by a Contributor License
9 | Agreement (CLA). You (or your employer) retain the copyright to your
10 | contribution; this simply gives us permission to use and redistribute your
11 | contributions as part of the project. Head over to
12 | to see your current agreements on file or
13 | to sign a new one.
14 |
15 | You generally only need to submit a CLA once, so if you've already submitted one
16 | (even if it was for a different project), you probably don't need to do it
17 | again.
18 |
19 | ## Code reviews
20 |
21 | All submissions, including submissions by project members, require review. We
22 | use GitHub pull requests for this purpose. Consult
23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
24 | information on using pull requests.
25 |
26 | ## Community Guidelines
27 |
28 | This project follows
29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/).
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2017 Google Inc.
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Puppeteer Recorder (no longer maintained) [](https://www.npmjs.com/package/@puppeteer/recorder)
2 |
3 |
4 |
5 | > :warning: This package was a prototype for what can now be found in Chromium DevTools as the _Recorder_ experiment and will no longer be maintained.
6 |
7 | > Puppeteer is a Node.js library which provides a high-level API to control Chrome or Chromium over the [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/).
8 |
9 | This repository allows you to record Puppeteer scripts by interacting with the browser.
10 |
11 | To start a new recording:
12 |
13 | ```bash
14 | npx @puppeteer/recorder [url]
15 | ```
16 |
17 | Every interaction with the page will be recorded and printed to the console as a script, which you can run with puppeteer.
18 | __For now, this will download Chromium every time again. This has to be addressed on the puppeteer side. As a workaround, build this package locally (see [Setup](#setup)).__
19 |
20 | ```js
21 | const {open, click, type, submit} = require('@puppeteer/recorder');
22 | open('https://www.google.com/?hl=en', async () => {
23 | await click('ariaName/Search');
24 | await type('ariaName/Search', 'calculator');
25 | await click('ariaName/Google Search');
26 | await click('ariaName/1');
27 | await click('ariaName/plus');
28 | await click('ariaName/2');
29 | await click('ariaName/equals');
30 | });
31 | ```
32 |
33 | ## Command line options
34 |
35 | - Pass `--output file.js` to write the output script to a file
36 |
37 | ## Architecture
38 |
39 | This project consists of three parts:
40 | - __Recorder__: A CLI script that starts a Chromium instance to record user interactions
41 | - __Runner__: An NPM package to abstract away the puppeteer details when running a recording
42 | - __Injected Script__: The recorder injects this script into the browser to collect user interactions
43 |
44 | ### Selectors
45 |
46 | The usual way of identifying elements within a website is to use a CSS selector. But a lot of websites use
47 | automatically generated class names that do not carry any semantic value, and change frequently.
48 | To increase the reliability of scripts generated with this tool, we query using the ARIA model.
49 | Instead of
50 | ```
51 | #tsf > div:nth-child(2) > div.A8SBwf > div.RNNXgb > div > div.a4bIc > input
52 | ```
53 | the same element can also be identified by
54 | ```
55 | combobox[name="Search"]
56 | ```
57 |
58 | ## Setup
59 |
60 | You can also check out this repository locally.
61 | To compile the _injected script_, the _recorder_ and the _runner_:
62 |
63 | ```bash
64 | npm install
65 | npm run build
66 | ```
67 |
68 | To make the package available to run via `npx`:
69 | ```bash
70 | npm link
71 | ```
72 |
73 | To run the package via `npx`:
74 | ```bash
75 | npx recorder [url]
76 | ```
77 |
78 | When running a recorded script, make sure this package is available in the local `node_modules` folder:
79 |
80 | ```bash
81 | npm link @puppeteer/recorder
82 | ```
83 |
84 | ## Debugging
85 |
86 | Use the runner with `DEBUG=1` to execute the script line by line.
87 |
88 | ## For maintainers
89 |
90 | ### How to publish new releases to npm
91 |
92 | 1. On the `main` branch, bump the version number in `package.json`:
93 |
94 | ```sh
95 | npm version patch -m 'Release v%s'
96 | ```
97 |
98 | Instead of `patch`, use `minor` or `major` [as needed](https://semver.org/).
99 |
100 | Note that this produces a Git commit + tag.
101 |
102 | 1. Push the release commit and tag:
103 |
104 | ```sh
105 | git push # push the commit
106 | git push origin v0.1.2 # push the tag
107 | ```
108 |
109 | Our CI then automatically publishes the new release to npm.
110 |
111 | ## Known limitations
112 |
113 | There are a number of known limitations:
114 | - ~~It's currently not possible to record interactions inside of [shadow doms](https://github.com/puppeteer/recorder/issues/4)~~
115 | - It only records clicks, changes to text fields and form submits for now
116 | - It does not handle [Out-of-Process iframes](https://www.chromium.org/developers/design-documents/oop-iframes) ([See Bug](https://github.com/puppeteer/recorder/issues/20))
117 |
118 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google Inc. All rights reserved.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | module.exports = {
18 | preset: 'ts-jest',
19 | testEnvironment: 'node',
20 | roots: ['./test/'],
21 | collectCoverageFrom: ['src/*.{js,ts}'],
22 | };
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@puppeteer/recorder",
3 | "version": "0.0.4",
4 | "description": "Interactively record puppeteer scripts",
5 | "main": "./lib/runner.js",
6 | "bin": {
7 | "@puppeteer/recorder": "./lib/cli.js"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/puppeteer/recorder.git"
12 | },
13 | "dependencies": {
14 | "css.escape": "^1.5.1",
15 | "expect": "^26.1.0",
16 | "puppeteer": "^5.4.0"
17 | },
18 | "devDependencies": {
19 | "@types/jest": "^25.2.3",
20 | "@types/node": "^14.0.20",
21 | "@types/puppeteer": "^3.0.1",
22 | "@typescript-eslint/eslint-plugin": "^4.5.0",
23 | "@typescript-eslint/parser": "^4.5.0",
24 | "devtools-protocol": "0.0.820307",
25 | "eslint": "^7.11.0",
26 | "eslint-config-prettier": "^6.12.0",
27 | "eslint-plugin-import": "^2.22.0",
28 | "eslint-plugin-jest": "^24.1.0",
29 | "eslint-plugin-prettier": "^3.1.4",
30 | "eslint-plugin-unicorn": "^22.0.0",
31 | "express": "^4.17.1",
32 | "jest": "^26.0.1",
33 | "prettier": "^2.0.5",
34 | "ts-jest": "^26.0.0",
35 | "ts-node": "^8.10.2",
36 | "typescript": "^3.9.2"
37 | },
38 | "scripts": {
39 | "test": "npm run build && jest",
40 | "build": "tsc",
41 | "dev": "ts-node ./src/cli.ts",
42 | "eslint": "([ \"$CI\" = true ] && eslint --ext js --ext ts --quiet -f codeframe . || eslint --ext js --ext ts .)",
43 | "eslint-fix": "eslint --ext js --ext ts --fix ."
44 | },
45 | "author": "The Chromium Authors",
46 | "license": "Apache-2.0"
47 | }
48 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | trailingComma: 'es5',
4 | singleQuote: true,
5 | };
6 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Copyright 2020 Google Inc. All rights reserved.
5 | *
6 | * Licensed under the Apache License, Version 2.0 (the "License");
7 | * you may not use this file except in compliance with the License.
8 | * You may obtain a copy of the License at
9 | *
10 | * http://www.apache.org/licenses/LICENSE-2.0
11 | *
12 | * Unless required by applicable law or agreed to in writing, software
13 | * distributed under the License is distributed on an "AS IS" BASIS,
14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | * See the License for the specific language governing permissions and
16 | * limitations under the License.
17 | */
18 |
19 | /* eslint-disable @typescript-eslint/no-var-requires */
20 |
21 | const [, , url] = process.argv;
22 |
23 | if (!url) {
24 | console.error('url required.');
25 | process.exit(1);
26 | } else {
27 | require('./recorder')
28 | .default(url)
29 | .then((output) => {
30 | output.pipe(process.stdout);
31 |
32 | // Check if the output should also be written to a file
33 | const fileNameIndex = process.argv.indexOf('--output');
34 | if (fileNameIndex !== -1) {
35 | if (fileNameIndex === process.argv.length) {
36 | throw new Error('Filename required when passing --output.');
37 | }
38 |
39 | const fileName = process.argv[fileNameIndex + 1];
40 | output.pipe(require('fs').createWriteStream(fileName, {}));
41 | }
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/src/helpers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google Inc. All rights reserved.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * This implementation heavily inspired by DevTools DOMPath implementation:
19 | * https://source.chromium.org/chromium/chromium/src/+/master:third_party/devtools-frontend/src/front_end/elements/DOMPath.js
20 | */
21 |
22 | export function getParent(): Element {
23 | if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
24 | return this.parentNode.host;
25 | } else {
26 | return this.parentElement;
27 | }
28 | }
29 |
30 | export function isSubmitButton(): boolean {
31 | return (
32 | this.tagName === 'BUTTON' &&
33 | (this as HTMLButtonElement).type === 'submit' &&
34 | (this as HTMLButtonElement).form !== null
35 | );
36 | }
37 |
38 | export function cssPath(): string {
39 | if (this.nodeType !== Node.ELEMENT_NODE) {
40 | return '';
41 | }
42 | function idSelector(id: string) {
43 | return '#' + CSS.escape(id);
44 | }
45 |
46 | class Step {
47 | public readonly value: string;
48 | public readonly optimized: boolean;
49 |
50 | constructor(value: string, optimized: boolean) {
51 | this.value = value;
52 | this.optimized = optimized;
53 | }
54 | toString() {
55 | return this.value;
56 | }
57 | }
58 |
59 | function cssPathStep(node: Element, isTargetNode: boolean): Step {
60 | if (node.nodeType !== Node.ELEMENT_NODE) {
61 | return null;
62 | }
63 | const id = node.getAttribute('id');
64 | if (id) {
65 | return new Step(idSelector(id), true);
66 | }
67 | const nodeNameLower = node.nodeName.toLowerCase();
68 | if (['html', 'body', 'head'].includes(nodeNameLower)) {
69 | return new Step(nodeNameLower, true);
70 | }
71 | const nodeName = node.nodeName;
72 | const parent = node.parentNode;
73 | if (!parent || parent.nodeType === Node.DOCUMENT_NODE) {
74 | return new Step(nodeNameLower, true);
75 | }
76 | let needsClassNames = false;
77 | let needsNthChild = false;
78 | let ownIndex = -1;
79 | let elementIndex = -1;
80 | const siblings = parent.children;
81 | const ownClassNames = new Set(node.classList);
82 | for (
83 | let i = 0;
84 | (ownIndex === -1 || !needsNthChild) && i < siblings.length;
85 | i++
86 | ) {
87 | const sibling = siblings[i];
88 | if (sibling.nodeType !== Node.ELEMENT_NODE) {
89 | continue;
90 | }
91 | elementIndex += 1;
92 | if (sibling === node) {
93 | ownIndex = elementIndex;
94 | continue;
95 | }
96 | if (sibling.nodeName !== nodeName) {
97 | continue;
98 | }
99 | needsClassNames = true;
100 | if (!ownClassNames.size) {
101 | needsNthChild = true;
102 | continue;
103 | }
104 | const siblingClassNames = new Set(sibling.classList);
105 | for (const siblingClass of siblingClassNames) {
106 | if (!ownClassNames.has(siblingClass)) {
107 | continue;
108 | }
109 | ownClassNames.delete(siblingClass);
110 | if (!ownClassNames.size) {
111 | needsNthChild = true;
112 | break;
113 | }
114 | }
115 | }
116 | let result = nodeNameLower;
117 | if (
118 | isTargetNode &&
119 | nodeName.toLowerCase() === 'input' &&
120 | node.getAttribute('type') &&
121 | !node.getAttribute('id') &&
122 | !node.getAttribute('class')
123 | ) {
124 | result += `[type=${CSS.escape(node.getAttribute('type'))}]`;
125 | }
126 | if (needsNthChild) {
127 | result += `:nth-child(${ownIndex + 1})`;
128 | } else if (needsClassNames) {
129 | for (const className of ownClassNames) {
130 | result += '.' + CSS.escape(className);
131 | }
132 | }
133 | return new Step(result, false);
134 | }
135 | const steps = [];
136 | // eslint-disable-next-line @typescript-eslint/no-this-alias
137 | let currentNode = this;
138 | while (currentNode) {
139 | const step = cssPathStep(currentNode, currentNode === this);
140 | if (!step) {
141 | break;
142 | }
143 | steps.push(step);
144 | if (step.optimized) {
145 | break;
146 | }
147 | currentNode = currentNode.parentNode as Element;
148 | }
149 | steps.reverse();
150 | return steps.join(' > ');
151 | }
152 |
--------------------------------------------------------------------------------
/src/recorder.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google Inc. All rights reserved.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import * as puppeteer from 'puppeteer';
18 | import { Readable } from 'stream';
19 | import * as helpers from './helpers';
20 | import * as protocol from 'devtools-protocol';
21 | import { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js';
22 |
23 | declare module 'puppeteer' {
24 | interface ElementHandle {
25 | _remoteObject: { objectId: string };
26 | }
27 | interface Page {
28 | _client: puppeteer.CDPSession;
29 | }
30 | interface CDPSession {
31 | send(
32 | method: T,
33 | ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
34 | ): Promise;
35 | }
36 | }
37 |
38 | interface RecorderOptions {
39 | wsEndpoint?: string;
40 | }
41 |
42 | async function getBrowserInstance(options: RecorderOptions) {
43 | if (options && options.wsEndpoint) {
44 | return puppeteer.connect({ browserWSEndpoint: options.wsEndpoint });
45 | } else {
46 | return puppeteer.launch({
47 | headless: false,
48 | defaultViewport: null,
49 | });
50 | }
51 | }
52 |
53 | function escapeSelector(selector: string): string {
54 | return JSON.stringify(selector);
55 | }
56 |
57 | export async function isSubmitButton(
58 | client: puppeteer.CDPSession,
59 | objectId: string
60 | ): Promise {
61 | const isSubmitButtonResponse = await client.send('Runtime.callFunctionOn', {
62 | functionDeclaration: helpers.isSubmitButton.toString(),
63 | objectId,
64 | });
65 | return isSubmitButtonResponse.result.value;
66 | }
67 |
68 | type AXNode = protocol.Protocol.Accessibility.AXNode;
69 | type CDPSession = puppeteer.CDPSession;
70 |
71 | // We check that a selector uniquely selects an element by querying the
72 | // selector and checking that all found elements are in the subtree of the
73 | // target.
74 | async function checkUnique(
75 | client: CDPSession,
76 | ignored: AXNode[],
77 | name?: string,
78 | role?: string
79 | ) {
80 | const { root } = await client.send('DOM.getDocument', { depth: 0 });
81 | const checkName = await client.send('Accessibility.queryAXTree', {
82 | backendNodeId: root.backendNodeId,
83 | accessibleName: name,
84 | role: role,
85 | });
86 | const ignoredIds = new Set(ignored.map((axNode) => axNode.backendDOMNodeId));
87 | const checkNameMinusTargetTree = checkName.nodes.filter(
88 | (axNode) => !ignoredIds.has(axNode.backendDOMNodeId)
89 | );
90 | return checkNameMinusTargetTree.length < 2;
91 | }
92 |
93 | export async function getSelector(
94 | client: puppeteer.CDPSession,
95 | objectId: string
96 | ): Promise {
97 | let currentObjectId = objectId;
98 | let prevName = '';
99 | while (currentObjectId) {
100 | const queryResp = await client.send('Accessibility.queryAXTree', {
101 | objectId: currentObjectId,
102 | });
103 | const targetNodes = queryResp.nodes;
104 | if (targetNodes.length === 0) break;
105 | const axNode = targetNodes[0];
106 | const name: string = axNode.name.value;
107 | const role: string = axNode.role.value;
108 | // If the name does not include the child name, we have probably reached a
109 | // completely different entity so we give up and pick a CSS selector.
110 | if (!name.includes(prevName)) break;
111 | prevName = name;
112 | const uniqueName = await checkUnique(client, targetNodes, name);
113 | if (name && uniqueName) {
114 | return `aria/${name}`;
115 | }
116 | const uniqueNameRole = await checkUnique(client, targetNodes, name, role);
117 | if (name && role && uniqueNameRole) {
118 | return `aria/${name}[role="${role}"]`;
119 | }
120 | const { result } = await client.send('Runtime.callFunctionOn', {
121 | functionDeclaration: helpers.getParent.toString(),
122 | objectId: currentObjectId,
123 | });
124 | currentObjectId = result.objectId;
125 | }
126 | const { result } = await client.send('Runtime.callFunctionOn', {
127 | functionDeclaration: helpers.cssPath.toString(),
128 | objectId,
129 | });
130 | return result.value;
131 | }
132 |
133 | export default async (
134 | url: string,
135 | options: RecorderOptions = {}
136 | ): Promise => {
137 | if (!url.startsWith('http')) {
138 | url = 'https://' + url;
139 | }
140 |
141 | const output = new Readable({
142 | read(size) {},
143 | });
144 | output.setEncoding('utf8');
145 | const browser = await getBrowserInstance(options);
146 | const page = await browser.pages().then((pages) => pages[0]);
147 | const client = page._client;
148 | page.on('domcontentloaded', async () => {
149 | await client.send('Debugger.enable', {});
150 | await client.send('DOMDebugger.setEventListenerBreakpoint', {
151 | eventName: 'click',
152 | });
153 | await client.send('DOMDebugger.setEventListenerBreakpoint', {
154 | eventName: 'change',
155 | });
156 | await client.send('DOMDebugger.setEventListenerBreakpoint', {
157 | eventName: 'submit',
158 | });
159 | // The heuristics we have for recording scrolling are quite fragile and
160 | // does not capture a reasonable set of scroll actions so we have decided
161 | // to disable it fow now
162 | /*
163 | await client.send('DOMDebugger.setEventListenerBreakpoint', {
164 | eventName: 'scroll',
165 | });
166 | */
167 | });
168 |
169 | const findTargetId = async (localFrame, interestingClassNames: string[]) => {
170 | const event = localFrame.find((prop) =>
171 | interestingClassNames.includes(prop.value.className)
172 | );
173 | const eventProperties = await client.send('Runtime.getProperties', {
174 | objectId: event.value.objectId as string,
175 | });
176 | const target = eventProperties.result.find(
177 | (prop) => prop.name === 'target'
178 | );
179 | return target.value.objectId;
180 | };
181 |
182 | const skip = async () => {
183 | await client.send('Debugger.resume', { terminateOnResume: false });
184 | };
185 | const resume = async () => {
186 | await client.send('Debugger.setSkipAllPauses', { skip: true });
187 | await skip();
188 | await client.send('Debugger.setSkipAllPauses', { skip: false });
189 | };
190 |
191 | const handleClickEvent = async (localFrame) => {
192 | const targetId = await findTargetId(localFrame, [
193 | 'MouseEvent',
194 | 'PointerEvent',
195 | ]);
196 | // Let submit handle this case if the click is on a submit button.
197 | if (await isSubmitButton(client, targetId)) {
198 | return skip();
199 | }
200 | const selector = await getSelector(client, targetId);
201 | if (selector) {
202 | addLineToPuppeteerScript(`await click(${escapeSelector(selector)});`);
203 | } else {
204 | console.log(`failed to generate selector`);
205 | }
206 | await resume();
207 | };
208 |
209 | const handleSubmitEvent = async (localFrame) => {
210 | const targetId = await findTargetId(localFrame, ['SubmitEvent']);
211 | const selector = await getSelector(client, targetId);
212 | if (selector) {
213 | addLineToPuppeteerScript(`await submit(${escapeSelector(selector)});`);
214 | } else {
215 | console.log(`failed to generate selector`);
216 | }
217 | await resume();
218 | };
219 |
220 | const handleChangeEvent = async (localFrame) => {
221 | const targetId = await findTargetId(localFrame, ['Event']);
222 | const targetValue = await client.send('Runtime.callFunctionOn', {
223 | functionDeclaration: 'function() { return this.value }',
224 | objectId: targetId,
225 | });
226 | const value = targetValue.result.value;
227 | const selector = await getSelector(client, targetId);
228 | addLineToPuppeteerScript(
229 | `await type(${escapeSelector(selector)}, ${escapeSelector(value)});`
230 | );
231 | await resume();
232 | };
233 |
234 | let scrollTimeout = null;
235 | const handleScrollEvent = async () => {
236 | if (scrollTimeout) return resume();
237 | const prevScrollHeightResp = await client.send('Runtime.evaluate', {
238 | expression: 'document.scrollingElement.scrollHeight',
239 | });
240 | const prevScrollHeight = prevScrollHeightResp.result.value;
241 | scrollTimeout = new Promise(function (resolve) {
242 | setTimeout(async () => {
243 | const currentScrollHeightResp = await client.send('Runtime.evaluate', {
244 | expression: 'document.scrollingElement.scrollHeight',
245 | });
246 | const currentScrollHeight = currentScrollHeightResp.result.value;
247 | if (currentScrollHeight > prevScrollHeight) {
248 | addLineToPuppeteerScript(`await scrollToBottom();`);
249 | }
250 | scrollTimeout = null;
251 | resolve();
252 | }, 1000);
253 | });
254 | await resume();
255 | };
256 |
257 | client.on('Debugger.paused', async function (
258 | pausedEvent: protocol.Protocol.Debugger.PausedEvent
259 | ) {
260 | const eventName = pausedEvent.data.eventName;
261 | const localFrame = pausedEvent.callFrames[0].scopeChain[0];
262 | const { result } = await client.send('Runtime.getProperties', {
263 | objectId: localFrame.object.objectId,
264 | });
265 | if (eventName === 'listener:click') {
266 | await handleClickEvent(result);
267 | } else if (eventName === 'listener:submit') {
268 | await handleSubmitEvent(result);
269 | } else if (eventName === 'listener:change') {
270 | await handleChangeEvent(result);
271 | } else if (eventName === 'listener:scroll') {
272 | await handleScrollEvent();
273 | } else {
274 | await skip();
275 | }
276 | });
277 |
278 | let identation = 0;
279 | const addLineToPuppeteerScript = (line: string) => {
280 | const data = ' '.repeat(identation) + line;
281 | output.push(data + '\n');
282 | };
283 |
284 | page.evaluateOnNewDocument(() => {
285 | window.addEventListener('change', (event) => {}, true);
286 | window.addEventListener('click', (event) => {}, true);
287 | window.addEventListener('submit', (event) => {}, true);
288 | window.addEventListener('scroll', () => {}, true);
289 | });
290 |
291 | // Setup puppeteer
292 | addLineToPuppeteerScript(
293 | `const {open, click, type, submit, expect, scrollToBottom} = require('@puppeteer/recorder');`
294 | );
295 | addLineToPuppeteerScript(`open('${url}', {}, async (page) => {`);
296 | identation += 1;
297 |
298 | // Open the initial page
299 | await page.goto(url);
300 |
301 | // Add expectations for mainframe navigations
302 | page.on('framenavigated', async (frame: puppeteer.Frame) => {
303 | if (frame.parentFrame()) return;
304 | addLineToPuppeteerScript(
305 | `expect(page.url()).resolves.toBe(${escapeSelector(frame.url())});`
306 | );
307 | });
308 |
309 | async function close() {
310 | identation -= 1;
311 | addLineToPuppeteerScript(`});`);
312 | output.push(null);
313 |
314 | // In case we started the browser instance
315 | if (!options.wsEndpoint) {
316 | // Close it
317 | await browser.close();
318 | }
319 | }
320 |
321 | // Finish the puppeteer script when the page is closed
322 | page.on('close', close);
323 | // Or if the user stops the script
324 | process.on('SIGINT', async () => {
325 | await close();
326 | process.exit();
327 | });
328 |
329 | return output;
330 | };
331 |
--------------------------------------------------------------------------------
/src/runner.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google Inc. All rights reserved.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import * as timers from 'timers';
18 | import * as puppeteer from 'puppeteer';
19 | import * as readline from 'readline';
20 | import * as expect from 'expect';
21 |
22 | export { expect };
23 |
24 | declare const __dirname;
25 |
26 | const timeout = (t) => new Promise((cb) => timers.setTimeout(cb, t));
27 |
28 | let browser, page;
29 | let delay = 100;
30 | const debug = process.env.DEBUG;
31 |
32 | interface RunnerOptions {
33 | delay: number;
34 | }
35 |
36 | async function beforeStep(...args) {
37 | console.log(...args);
38 |
39 | if (!debug) {
40 | await timeout(delay);
41 | return;
42 | }
43 |
44 | const rl = readline.createInterface({
45 | input: process.stdin,
46 | output: process.stdout,
47 | });
48 |
49 | await new Promise((resolve) =>
50 | rl.question('Press enter to execute this step?', (ans) => {
51 | rl.close();
52 | resolve(ans);
53 | })
54 | );
55 | }
56 |
57 | export async function open(url, options: RunnerOptions, cb) {
58 | delay = options.delay || 100;
59 | browser = await puppeteer.launch({
60 | headless: false,
61 | defaultViewport: null,
62 | });
63 | const pages = await browser.pages();
64 | page = pages[0];
65 | await page.goto(url);
66 | await timeout(1000);
67 | await cb(page, browser);
68 | await browser.close();
69 | }
70 |
71 | export async function click(selector) {
72 | await beforeStep('click', selector);
73 | const element = await page.waitForSelector(selector, { visible: true });
74 | await element.click();
75 | }
76 |
77 | export async function type(selector, value) {
78 | await beforeStep('type', selector, value);
79 | const element = await page.waitForSelector(selector, { visible: true });
80 | await element.click({ clickCount: 3 });
81 | await element.press('Backspace');
82 | await element.type(value);
83 | }
84 |
85 | export async function submit(selector) {
86 | await beforeStep('submit', selector);
87 | await page.$eval(selector, (form) => form.requestSubmit());
88 | }
89 |
90 | export async function scrollToBottom() {
91 | await beforeStep('scrollToBottom');
92 | await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight));
93 | }
94 |
--------------------------------------------------------------------------------
/test/getselector.spec.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment node
3 | */
4 |
5 | /**
6 | * Copyright 2020 Google Inc. All rights reserved.
7 | *
8 | * Licensed under the Apache License, Version 2.0 (the "License");
9 | * you may not use this file except in compliance with the License.
10 | * You may obtain a copy of the License at
11 | *
12 | * http://www.apache.org/licenses/LICENSE-2.0
13 | *
14 | * Unless required by applicable law or agreed to in writing, software
15 | * distributed under the License is distributed on an "AS IS" BASIS,
16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 | * See the License for the specific language governing permissions and
18 | * limitations under the License.
19 | */
20 |
21 | import * as puppeteer from 'puppeteer';
22 | import { getSelector, isSubmitButton } from '../src/recorder';
23 |
24 | declare module 'puppeteer' {
25 | interface ElementHandle {
26 | _remoteObject: { objectId: string };
27 | }
28 | interface Page {
29 | _client: puppeteer.CDPSession;
30 | }
31 | }
32 |
33 | let browser: puppeteer.Browser,
34 | page: puppeteer.Page,
35 | client: puppeteer.CDPSession;
36 |
37 | describe('DOM', () => {
38 | beforeAll(async () => {
39 | browser = await puppeteer.launch({ defaultViewport: null, headless: true });
40 | page = await browser.newPage();
41 | client = page._client;
42 | });
43 |
44 | afterAll(async () => {
45 | await browser.close();
46 | });
47 |
48 | describe('isSubmitButton', () => {
49 | it('should return true if the button is a submit button', async () => {
50 | await page.setContent(``);
51 |
52 | const element = await page.$('button');
53 | const isSubmitCheck = await isSubmitButton(
54 | client,
55 | element._remoteObject.objectId
56 | );
57 | expect(isSubmitCheck).toBe(true);
58 | });
59 |
60 | it('should return false if the button is not a submit button', async () => {
61 | await page.setContent(``);
62 | const element = await page.$('button');
63 | const isSubmitCheck = await isSubmitButton(
64 | client,
65 | element._remoteObject.objectId
66 | );
67 | expect(isSubmitCheck).toBe(false);
68 | });
69 | });
70 |
71 | describe('getSelector', () => {
72 | it('should return the aria name if it is available', async () => {
73 | await page.setContent(
74 | ``
75 | );
76 |
77 | const element = await page.$('button');
78 | const selector = await getSelector(
79 | client,
80 | element._remoteObject.objectId
81 | );
82 | expect(selector).toBe('aria/Hello World');
83 | });
84 |
85 | it('should return an aria name selector for the closest link or button', async () => {
86 | await page.setContent(
87 | ``
88 | );
89 |
90 | const element = await page.$('button');
91 | const selector = await getSelector(
92 | client,
93 | element._remoteObject.objectId
94 | );
95 | expect(selector).toBe('aria/Hello World');
96 | });
97 |
98 | it('should return name alone if it is unique', async () => {
99 | await page.setContent(``);
100 |
101 | const element = await page.$('button');
102 | const selector = await getSelector(
103 | client,
104 | element._remoteObject.objectId
105 | );
106 | expect(selector).toBe('aria/Hello World');
107 | });
108 | it('should include both name and role if name alone is not unique', async () => {
109 | await page.setContent(
110 | `
Hello World
`
111 | );
112 |
113 | const element = await page.$('button');
114 | const selector = await getSelector(
115 | client,
116 | element._remoteObject.objectId
117 | );
118 | expect(selector).toBe('aria/Hello World[role="button"]');
119 | });
120 |
121 | it('should return an aria name selector for the closest link or button if the text is not an exact match', async () => {
122 | await page.setContent(
123 | ``
124 | );
125 |
126 | const element = await page.$('#button');
127 | const selector = await getSelector(
128 | client,
129 | element._remoteObject.objectId
130 | );
131 | expect(selector).toBe('aria/Hello World');
132 | });
133 |
134 | it('should return css selector if the element is not identifiable by an aria selector 1', async () => {
135 | await page.setContent(
136 | ``
137 | );
138 |
139 | const element = await page.$('#button');
140 | const selector = await getSelector(
141 | client,
142 | element._remoteObject.objectId
143 | );
144 | expect(selector).toBe('#button');
145 | });
146 |
147 | it('should return css selector if the element is not identifiable by an aria selector 2', async () => {
148 | await page.setContent(``);
149 |
150 | const element = await page.$('span');
151 | const selector = await getSelector(
152 | client,
153 | element._remoteObject.objectId
154 | );
155 | expect(selector).toBe('body > form > div > span');
156 | });
157 |
158 | it('should pierce shadow roots to get an aria name', async () => {
159 | await page.setContent(
160 | `
161 |
175 | `
176 | );
177 | const link = await page.$('a');
178 | const selector = await getSelector(client, link._remoteObject.objectId);
179 | expect(selector).toBe('aria/Hello World');
180 | });
181 | });
182 | });
183 |
--------------------------------------------------------------------------------
/test/helpers.spec.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | /**
6 | * Copyright 2020 Google Inc. All rights reserved.
7 | *
8 | * Licensed under the Apache License, Version 2.0 (the "License");
9 | * you may not use this file except in compliance with the License.
10 | * You may obtain a copy of the License at
11 | *
12 | * http://www.apache.org/licenses/LICENSE-2.0
13 | *
14 | * Unless required by applicable law or agreed to in writing, software
15 | * distributed under the License is distributed on an "AS IS" BASIS,
16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 | * See the License for the specific language governing permissions and
18 | * limitations under the License.
19 | */
20 |
21 | import * as helpers from '../src/helpers';
22 | require('css.escape');
23 |
24 | const cssPath = (node: Node) => helpers.cssPath.bind(node)();
25 |
26 | describe('CSS Path', () => {
27 | it('should return an empty path if the given node is not an element node', () => {
28 | const path = cssPath(document);
29 | expect(path).toBe('');
30 | });
31 |
32 | it('should return an id selector if the node has an id', () => {
33 | document.body.innerHTML = ``;
34 | const node = document.querySelector('[data-id="test"]');
35 | const path = cssPath(node);
36 | expect(path).toBe('#test');
37 | });
38 |
39 | it('should return a path until the document', () => {
40 | document.body.innerHTML = `
`;
41 | const node = document.querySelector('[data-id="test"]');
42 | const path = cssPath(node);
43 | expect(path).toBe('body > div > p');
44 | });
45 |
46 | it('should ignore siblings that are not element nodes', () => {
47 | document.body.innerHTML = ` Hello World`;
48 | const node = document.querySelector('[data-id="test"]');
49 |
50 | const path = cssPath(node);
51 | expect(path).toBe('body > div');
52 | });
53 |
54 | it('should index children with nth-child if there are siblings with the same tag name', () => {
55 | document.body.innerHTML = ``;
56 | const node = document.querySelector('[data-id="test"]');
57 |
58 | const path = cssPath(node);
59 | expect(path).toBe('body > div:nth-child(3)');
60 | });
61 |
62 | it('should not use nth-child if siblings with the same tag name are distinguishable by class name', () => {
63 | document.body.innerHTML = ``;
64 | const node = document.querySelector('[data-id="test"]');
65 |
66 | const path = cssPath(node);
67 | expect(path).toBe('body > div.test3');
68 | });
69 |
70 | it('should use nth-child if siblings with the same tag name are not distinguishable by class name', () => {
71 | document.body.innerHTML = ``;
72 | const node = document.querySelector('[data-id="test"]');
73 |
74 | const path = cssPath(node);
75 | expect(path).toBe('body > div:nth-child(2)');
76 | });
77 |
78 | it('should include the type for input elements', () => {
79 | document.body.innerHTML = ``;
80 | const node = document.querySelector('[data-id="test"]');
81 |
82 | const path = cssPath(node);
83 | expect(path).toBe('body > input[type="email"]');
84 | });
85 |
86 | it('should escape properly', () => {
87 | document.body.innerHTML = ``;
88 | const node = document.querySelector('[data-id="test"]');
89 |
90 | const path = cssPath(node);
91 | expect(path).toBe('body > input[type="foo\\"bar"]');
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/test/public/iframe1.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Test Link
4 | Go to iframes
5 |
--------------------------------------------------------------------------------
/test/public/page2.html:
--------------------------------------------------------------------------------
1 |