├── .browserslistrc
├── .eslintrc.js
├── .github
├── auto_assign.yml
├── dependabot.yml
├── pull_request_template.md
└── workflows
│ ├── codeql-analysis.yml
│ └── lint-build-test.yml
├── .gitignore
├── .npmrc
├── .prettierrc.js
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── PRIVACY_POLICY.md
├── README.md
├── assets
├── checkly-logo.png
├── dev-guide.png
├── hr.gif
├── logo.png
├── rec.png
└── wait.png
├── babel.config.js
├── dependabot.yml
├── jest.config.js
├── jest.setup.js
├── jsconfig.json
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── _locales
│ └── en
│ │ └── messages.json
├── browser-extension.html
├── favicon.ico
├── icons
│ ├── 128.png
│ ├── 16.png
│ ├── 19.png
│ ├── 38.png
│ ├── 48.png
│ ├── dark
│ │ ├── clip.svg
│ │ ├── duplicate.svg
│ │ ├── pause.svg
│ │ ├── play.svg
│ │ ├── screen.svg
│ │ └── sync.svg
│ └── light
│ │ ├── clip.svg
│ │ ├── duplicate.svg
│ │ ├── pause.svg
│ │ ├── play.svg
│ │ ├── screen.svg
│ │ ├── sync.svg
│ │ └── zap.svg
└── images
│ ├── checkly-logo.svg
│ ├── logo-red.png
│ ├── logo-red@2x.png
│ ├── logo-red@3x.png
│ ├── logo-yellow.png
│ ├── logo-yellow@2x.png
│ ├── logo-yellow@3x.png
│ ├── logo.png
│ ├── logo@2x.png
│ └── logo@3x.png
├── src
├── __tests__
│ ├── build.spec.js
│ └── helpers.js
├── assets
│ ├── animations.css
│ ├── code.css
│ ├── icons
│ │ ├── gear.svg
│ │ ├── moon.svg
│ │ └── question.svg
│ └── tailwind.css
├── background
│ └── index.js
├── components
│ ├── Button.vue
│ ├── Footer.vue
│ ├── Header.vue
│ ├── RecordingLabel.vue
│ ├── RoundButton.vue
│ ├── Toggle.vue
│ └── __tests__
│ │ ├── RecordingTab.spec.js
│ │ ├── ResultsTab.spec.js
│ │ └── __snapshots__
│ │ ├── RecordingTab.spec.js.snap
│ │ └── ResultsTab.spec.js.snap
├── content-scripts
│ ├── __tests__
│ │ ├── attributes.spec.js
│ │ ├── fixtures
│ │ │ ├── attributes.html
│ │ │ └── forms.html
│ │ ├── forms.spec.js
│ │ ├── helpers.js
│ │ └── screenshot-controller.spec.js
│ ├── controller.js
│ └── index.js
├── manifest.json
├── modules
│ ├── code-generator
│ │ ├── __tests__
│ │ │ ├── playwright-code-generator.spec.js
│ │ │ └── puppeteer-code-generator.spec.js
│ │ ├── base-generator.js
│ │ ├── block.js
│ │ ├── constants.js
│ │ ├── index.js
│ │ ├── playwright.js
│ │ └── puppeteer.js
│ ├── overlay
│ │ ├── Overlay.vue
│ │ ├── Selector.vue
│ │ ├── constants.js
│ │ └── index.js
│ ├── recorder
│ │ └── index.js
│ └── shooter
│ │ └── index.js
├── options
│ ├── OptionsApp.vue
│ ├── __tests__
│ │ ├── App.spec.js
│ │ └── __snapshots__
│ │ │ └── App.spec.js.snap
│ └── main.js
├── popup
│ ├── PopupApp.vue
│ ├── __tests__
│ │ ├── App.spec.js
│ │ └── __snapshots__
│ │ │ └── App.spec.js.snap
│ └── main.js
├── services
│ ├── __tests__
│ │ ├── analytics.spec.js
│ │ ├── badge.spec.js
│ │ ├── browser.spec.js
│ │ ├── constants.spec.js
│ │ └── storage.spec.js
│ ├── analytics.js
│ ├── badge.js
│ ├── browser.js
│ ├── constants.js
│ ├── selector.js
│ └── storage.js
├── store
│ └── index.js
└── views
│ ├── Home.vue
│ ├── Recording.vue
│ └── Results.vue
├── tailwind.config.js
└── vue.config.js
/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 | not dead
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 |
4 | env: {
5 | node: true,
6 | webextensions: true,
7 | },
8 |
9 | extends: [
10 | 'plugin:vuejs-accessibility/recommended',
11 | 'plugin:vue/vue3-essential',
12 | 'eslint:recommended',
13 | '@vue/prettier',
14 | ],
15 |
16 | parserOptions: {
17 | parser: 'babel-eslint',
18 | },
19 |
20 | rules: {
21 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
22 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
23 | 'vuejs-accessibility/label-has-for': 'off',
24 | },
25 |
26 | overrides: [
27 | {
28 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'],
29 | env: {
30 | jest: true,
31 | },
32 | },
33 | ],
34 | }
35 |
--------------------------------------------------------------------------------
/.github/auto_assign.yml:
--------------------------------------------------------------------------------
1 | addReviewers: true
2 | addAssignees: author
3 |
4 | reviewers:
5 | - pilimartinez
6 | - ianaya89
7 |
8 | skipKeywords:
9 | - wip
10 |
11 | numberOfReviewers: 1
12 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | # Runs at 9am CET only on weekdays
6 | schedule:
7 | time: "13:00"
8 | interval: "daily"
9 | timezone: Europe/Berlin
10 | open-pull-requests-limit: 3
11 | labels:
12 | - "dependencies"
13 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Pull Request Template
2 |
3 | ## Description
4 |
5 | Please include a summary of the change or which issue is fixed. Also, include relevant motivation and context.
6 | Remember, as mentioned in the [contribution guidelines](https://github.com/checkly/puppeteer-recorder/blob/main/CONTRIBUTING.md) that
7 | PR's should be as atomic as possible 1 feature === 1 PR. 1 bugfix === 1 PR.
8 |
9 | ## Type of change
10 |
11 | Please delete options that are not relevant.
12 |
13 | - [ ] Bug fix (non-breaking change which fixes an issue)
14 | - [ ] New feature (non-breaking change which adds functionality)
15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
16 | - [ ] This change requires a documentation update
17 |
18 | ## How Has This Been Tested?
19 |
20 | Please describe the tests that you ran to verify your changes.
21 |
22 | ## Checklist:
23 |
24 | - [ ] My code follows the style guidelines of this project. `npm run lint` passes with no errors.
25 | - [ ] I have made corresponding changes to the documentation
26 | - [ ] I have added tests that prove my fix is effective or that my feature works
27 | - [ ] New and existing unit tests pass locally with my changes. `npm run test` passes with no errors.
28 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 | schedule:
9 | - cron: '38 6 * * 5'
10 |
11 | jobs:
12 | analyze:
13 | name: Analyze
14 | runs-on: ubuntu-latest
15 | permissions:
16 | actions: read
17 | contents: read
18 | security-events: write
19 |
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | language: [ 'javascript' ]
24 |
25 | steps:
26 | - name: Checkout repository
27 | uses: actions/checkout@v2
28 |
29 | # Initializes the CodeQL tools for scanning.
30 | - name: Initialize CodeQL
31 | uses: github/codeql-action/init@v1
32 | with:
33 | languages: ${{ matrix.language }}
34 | # If you wish to specify custom queries, you can do so here or in a config file.
35 | # By default, queries listed here will override any specified in a config file.
36 | # Prefix the list here with "+" to use these queries and those in the config file.
37 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
38 |
39 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
40 | # If this step fails, then you should remove it and run the build manually (see below)
41 | - name: Autobuild
42 | uses: github/codeql-action/autobuild@v1
43 |
44 | # ℹ️ Command-line programs to run using the OS shell.
45 | # 📚 https://git.io/JvXDl
46 |
47 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
48 | # and modify them (or add more) to build your code if your project
49 | # uses a compiled language
50 |
51 | #- run: |
52 | # make bootstrap
53 | # make release
54 |
55 | - name: Perform CodeQL Analysis
56 | uses: github/codeql-action/analyze@v1
57 |
--------------------------------------------------------------------------------
/.github/workflows/lint-build-test.yml:
--------------------------------------------------------------------------------
1 | name: Lint & Build & Test
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | types: [ opened, synchronize ]
8 |
9 | jobs:
10 | dependencies:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 | - run: PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm install
15 | - uses: actions/cache@v1
16 | id: cache-dependencies
17 | with:
18 | path: '.'
19 | key: ${{ github.sha }}
20 |
21 | ci:
22 | runs-on: ubuntu-latest
23 | needs: dependencies
24 | steps:
25 | - uses: actions/cache@v1
26 | id: restore-dependencies
27 | with:
28 | path: '.'
29 | key: ${{ github.sha }}
30 | - name: Lint
31 | run: npm run lint
32 | - name: Build
33 | run: npm run build
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
25 | # Vue Browser Extension Output
26 | *.pem
27 | *.pub
28 | *.zip
29 | /artifacts
30 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
2 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | trailingComma: 'es5',
3 | tabWidth: 2,
4 | semi: false,
5 | singleQuote: true,
6 | printWidth: 100,
7 | }
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [1.0.0] - 2021-07-08
8 | ### Added
9 | - New visual identity by [@nucro](https://twitter.com/nucro).
10 | - In page overlay to handle recording and take screenshots
11 | - Visual feedback when taking screenshots
12 | - New code structure organized in modules and services
13 | - Dark mode support
14 | - Migrate to Vue 3 and dependencies update
15 | - Migrate CSS to Tailwind (except for Overlay components)
16 | - Selector preview while recording
17 | - Restart button while recording
18 | - Allow run scripts directly on Checkly 🦝
19 | - First draft of Vuex shared store
20 |
21 | ### Changed
22 | - Make Playwright default tab
23 | - Use non-async wrap as default
24 | - Full page screenshots use `fullPage` property
25 | - Replace clipped screenshots with element screenshots
26 | - Improve selector generation giving relevance to `ID` and `data-attributes` [#64](https://github.com/checkly/headless-recorder/issues/64)
27 | - General bug fixing
28 | - Improve code reusability and events management
29 |
30 | ### Removed
31 | - Screenshots context menu
32 | - Events recording list
33 |
34 |
35 |
36 | ## [0.8.2] - 2020-12-15
37 |
38 | ### Changed
39 | - Specify custom key for input record [#111](https://github.com/checkly/headless-recorder/pulls/111)
40 | - Fix input escaping [#119](https://github.com/checkly/headless-recorder/pulls/119)
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | HI! Thanks you for your interest in Puppeteer Recorder! We'd love to accept your patches and contributions, but please remember that this project was started first and foremost to serve the users of the Checkly API and Site transaction monitoring service.
4 |
5 | ## New feature guidelines
6 |
7 | When authoring new features or extending existing ones, consider the following:
8 | - All new features should be accompanied first with a Github issues describing the feature and its necessity.
9 | - We aim for simplicity. Too many options, buttons, panels etc. detract from that.
10 | - Features should serve the general public. Very specific things for your use case are frowned upon.
11 |
12 | ## Getting set up
13 |
14 | 1. Clone this repository
15 |
16 | ```bash
17 | git clone https://github.com/checkly/headless-recorder
18 | cd headless-recorder
19 | ```
20 |
21 | 2. Install dependencies
22 |
23 | ```bash
24 | npm install
25 | ```
26 |
27 | ## Code reviews
28 |
29 | All submissions, including submissions by project members, require review. We
30 | use GitHub pull requests for this purpose. Consult
31 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
32 | information on using pull requests.
33 |
34 | > Note: one pull request should cover one, atomic feature and/or bug fix. Do not submit pull requests with a plethora of updates, tweaks, fixes and new features.
35 |
36 | ## Code Style
37 |
38 | - Coding style is fully defined in [.eslintrc](https://github.com/checkly/headless-recorder/blob/main/.eslintrc.js)
39 | - Comments should be generally avoided. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory.
40 |
41 | To run code linter, use:
42 |
43 | ```bash
44 | npm run lint
45 | ```
46 | ## Commit Messages
47 |
48 | Commit messages should follow the Semantic Commit Messages format:
49 |
50 | ```
51 | label(namespace): title
52 |
53 | description
54 |
55 | footer
56 | ```
57 |
58 | 1. *label* is one of the following:
59 | - `fix` - puppeteer bug fixes.
60 | - `feat` - puppeteer features.
61 | - `docs` - changes to docs, e.g. `docs(api.md): ..` to change documentation.
62 | - `test` - changes to puppeteer tests infrastructure.
63 | - `style` - puppeteer code style: spaces/alignment/wrapping etc.
64 | - `chore` - build-related work, e.g. doclint changes / travis / appveyor.
65 | 2. *namespace* is put in parenthesis after label and is optional.
66 | 3. *title* is a brief summary of changes.
67 | 4. *description* is **optional**, new-line separated from title and is in present tense.
68 |
69 | Example:
70 |
71 | ```
72 | fix(code-generator): fix page.pizza method
73 |
74 | This patch fixes page.pizza so that it works with iframes.
75 |
76 | Fixes #123, Fixes #234
77 | ```
78 |
79 | ## Adding New Dependencies
80 |
81 | For all dependencies (both installation and development):
82 | - **Do not add** a dependency if the desired functionality is easily implementable.
83 | - If adding a dependency, it should be well-maintained and trustworthy.
84 |
85 | A barrier for introducing new installation dependencies is especially high:
86 | - **Do not add** installation dependency unless it's critical to project success.
87 |
88 | ## Writing Tests
89 |
90 | - Every feature should be accompanied by a test.
91 | - Every public api event/method should be accompanied by a test.
92 | - Tests should be *hermetic*. Tests should not depend on external services.
93 |
94 | We use Jest for testing. Tests are located in the various `__test__` folders.
95 |
96 | - To run all tests:
97 |
98 | ```bash
99 | npm run test
100 | ```
101 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Checkly Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/PRIVACY_POLICY.md:
--------------------------------------------------------------------------------
1 | # Privacy policy
2 | > Last Updated: July 12, 2021
3 |
4 | The Headless Recorder browser extension (hereinafter “Service”) provided by the company Checkly Inc. provides this Privacy Policy to inform users of our policies and procedures regarding the collection, use and disclosure of information received from users of this extension, located at https://github.com/checkly/headless-recorder (“Extension”), as well as https://chrome.google.com/webstore/detail/headless-recorder/djeegiggegleadkkbgopoonhjimgehda?hl=de, and other services provided by us (collectively, together with the Extension, our “Service”).
5 |
6 | By using our Service you are consenting to our Processing of your information as set forth in this Privacy Policy now and as amended by us. “Processing” means using cookies on a computer or using or accessing such information in any way, including, but not limited to, collecting, storing, deleting, using, combining and disclosing information, all of which activities may take place in the United States.
7 |
8 | ### TL;DR:
9 | - We will not sell your data to anyone.
10 | - We use Google Analytics to see how you interact with extension. You can opt out of both Google Analytics.
11 |
12 | ## 1. Information Collection and Use
13 |
14 | Our primary goals in collecting information from you are to provide you with the products and services made available through the Service.
15 | We may also use your information to operate, maintain, and enhance the Service and its features.
16 |
17 | ## 2. Log Data
18 |
19 | When use the Extension, our Google Analytics may record information that your browser sends whenever you visit a website (“Log Data”). This Log Data may include information such as your IP address, browser type or the domain from which you are visiting, the web-pages you visit, the search terms you use, and any advertisements on which you click. For most users accessing the Internet from an Internet service provider the IP address will be different every time you log on. We use Log Data to monitor the use of the Extension and of our Service, and for the Extension’s technical administration. We do not associate your IP address with any other personally identifiable information to identify you personally.
20 |
21 | ## 3. Cookies and Automatically Collected Information
22 |
23 | Like many websites, we also use “cookie” technology to collect additional usage data and to improve the Extension and our Service. A cookie is a small data file that we transfer to your computer’s hard disk. We do not use cookies to collect personally identifiable information. Checkly may use both session cookies and persistent cookies to better understand how you interact with the Extension and our Service, to monitor aggregate usage by our users, and to improve the Extension and our services. A persistent cookie remains after you close your browser and may be used by your browser on subsequent visits to the Extension. Persistent cookies can be removed by following your web browser help file directions. Most Internet browsers automatically accept cookies. You can instruct your browser, by editing its options, to stop accepting cookies or to prompt you before accepting a cookie from the websites you visit. Please note that if you delete, or choose not to accept, cookies from the Service, you may not be able to utilize certain features of the Service to their fullest potential.
24 | We may also automatically record certain information from your device by using various types of technology, including “clear gifs” or “web beacons.” This automatically collected information may include your IP address or other device address or ID, web browser and/or device type, the web pages or sites that you visit just before or just after you use the Service, the pages or other content you view or otherwise interact with on the Service, and the dates and times that you visit, access, or use the Service.
25 |
26 | ### Google Analytics
27 | We use Google Analytics to measure the effectiveness of our Extension.
28 |
29 | ## 4. Security
30 | Checkly is very concerned about safeguarding the confidentiality of your personally identifiable information. Please be aware that no security measures are perfect or impenetrable. We cannot and do not guarantee that information about you will not be accessed, viewed, disclosed, altered, or destroyed by breach of any of our administrative, physical, and electronic safeguards. We will make any legally-required disclosures of any breach of the security, confidentiality, or integrity of your unencrypted electronically stored personal data to you via email or conspicuous posting on this Extension in the most expedient time possible and without unreasonable delay, consistent with (i) the legitimate needs of law enforcement or (ii) any measures necessary to determine the scope of the breach and restore the reasonable integrity of the data system.
31 |
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚨 Deprecated!
2 | As of Dec 16th 2022, Headless Recorder is fully deprecated. No new changes, support, maintenance or new features are expected to land.
3 |
4 | For more information and possible alternatives refer to this [issue](https://github.com/checkly/headless-recorder/issues/232).
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Headless Recorder
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | > 🎥 Headless recorder is a Chrome extension that records your browser interactions and generates a Playwright/Puppeteer script.
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | ## Overview
34 |
35 | Headless recorder is a Chrome extension that records your browser interactions and generates a [Playwright](https://playwright.dev/) or [Puppeteer](http://pptr.dev/) script. Install it from the [Chrome Webstore](https://chrome.google.com/webstore/detail/puppeteer-recorder/djeegiggegleadkkbgopoonhjimgehda) to get started!
36 |
37 | This project builds on existing open source projects (see [Credits](#-credits)) but adds extensibility, configurability and a smoother UI. For more information, please check our [documentation](https://www.checklyhq.com/docs/headless-recorder/).
38 |
39 | > 🤔 Do you want to learn more about Puppeteer and Playwright? Check our open [Headless Guides](https://www.checklyhq.com/learn/headless/)
40 |
41 |
42 |
43 | ## What you can do?
44 |
45 | - Records clicks and type events.
46 | - Add waitForNavigation, setViewPort and other useful clauses.
47 | - Generates a Playwright & Puppeteer script.
48 | - Preview CSS selectors of HTML elements.
49 | - Take full page and element screenshots.
50 | - Pause, resume and restart recording.
51 | - Persist latest script in your browser
52 | - Copy to clipboard.
53 | - Run generated scripts directly on [Checkly](https://checklyhq.com)
54 | - Flexible configuration options and dark mode support.
55 | - Allows `data-id` configuration for element selection.
56 |
57 | #### Recorded Events
58 | - `click`
59 | - `dblclick`
60 | - `change`
61 | - `keydown`
62 | - `select`
63 | - `submit`
64 | - `load`
65 | - `unload`
66 |
67 | > This collection will be expanded in future releases. 💪
68 |
69 |
70 |
71 | ## How to use?
72 |
73 | 1. Click the icon and hit the red button.
74 | 2. 👉 Hit tab after you finish typing in an `input` element. 👈
75 | 3. Click on links, inputs and other elements.
76 | 4. Wait for full page load on each navigation.
77 |
78 | **The icon will switch from
79 | to to indicate it is ready for more input from you.**
80 |
81 | 5. Click Pause when you want to navigate without recording anything. Hit Resume to continue recording.
82 |
83 | ### ⌨️ Shortcuts
84 |
85 | - `alt + k`: Toggle overlay
86 | - `alt + shift + F`: Take full page screenshot
87 | - `alt + shift + E`: Take element screenshot
88 |
89 |
90 |
91 | ## Run Locally
92 |
93 | After cloning the project, open the terminal and navigate to project root directory.
94 |
95 | ```bash
96 | $ npm i # install dependencies
97 |
98 | $ npm run serve # run development mode
99 |
100 | $ npm run test # run test cases
101 |
102 | $ npm run lint # run and fix linter issues
103 |
104 | $ npm run build # build and zip for production
105 | ```
106 |
107 |
108 |
109 | ## Install Locally
110 |
111 | 1. Open chrome and navigate to extensions page using this URL: [`chrome://extensions`](chrome://extensions).
112 | 1. Make sure "**Developer mode**" is enabled.
113 | 1. Click "**Load unpacked extension**" button, browse the `headless-recorder/dist` directory and select it.
114 |
115 | 
116 |
117 |
118 |
119 | ## Release
120 |
121 | 1. Bump version using `npm version` (patch, minor, major).
122 | 2. Push changes with tags `git push --tags`
123 | 3. Generate a release using **gren**: `gren release --override --data-source=milestones --milestone-match="{{tag_name}}"`
124 |
125 | > 🚨 Make sure all issues associated with the new version are linked to a milestone with the name of the tag.
126 |
127 |
128 |
129 | ## Credits
130 |
131 | Headless recorder is the spiritual successor & love child of segment.io's [Daydream](https://github.com/segmentio/daydream) and [ui recorder](https://github.com/yguan/ui-recorder).
132 |
133 |
134 |
135 | ## License
136 |
137 | [MIT](https://github.com/checkly/headless-recorder/blob/main/LICENSE)
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | Delightful Active Monitoring for Developers
146 |
147 | From Checkly with ♥️
148 |
149 |
150 |
--------------------------------------------------------------------------------
/assets/checkly-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/assets/checkly-logo.png
--------------------------------------------------------------------------------
/assets/dev-guide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/assets/dev-guide.png
--------------------------------------------------------------------------------
/assets/hr.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/assets/hr.gif
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/assets/logo.png
--------------------------------------------------------------------------------
/assets/rec.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/assets/rec.png
--------------------------------------------------------------------------------
/assets/wait.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/assets/wait.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@vue/cli-plugin-babel/preset'],
3 | }
4 |
--------------------------------------------------------------------------------
/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | time: "09:00"
7 | interval: "daily"
8 | timezone: Europe/Berlin
9 | open-pull-requests-limit: 3
10 | labels:
11 | - "dependencies"
12 | reviewers:
13 | - "ianaya89"
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: '@vue/cli-plugin-unit-jest',
3 | transform: {
4 | '^.+\\.vue$': 'vue-jest',
5 | "^.+\\.js$": "babel-jest",
6 | },
7 | setupFilesAfterEnv: ['./jest.setup.js'],
8 | moduleFileExtensions: ["js", "json", "vue"],
9 | }
10 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom'
2 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "./src/**/*"
4 | ]
5 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "headless-recorder",
3 | "version": "1.1.0",
4 | "scripts": {
5 | "serve": "vue-cli-service build --mode development --watch",
6 | "build": "vue-cli-service build",
7 | "test": "npm run test:unit",
8 | "test:unit": "vue-cli-service test:unit __tests__/.*.spec.js",
9 | "lint": "vue-cli-service lint"
10 | },
11 | "dependencies": {
12 | "@headlessui/vue": "1.2.0",
13 | "@medv/finder": "2.0.0",
14 | "@tailwindcss/postcss7-compat": "2.0.2",
15 | "@vueuse/core": "4.0.8",
16 | "autoprefixer": "9",
17 | "core-js": "3.6.5",
18 | "css-selector-generator": "3.0.1",
19 | "lodash": "4.17.21",
20 | "pinia": "2.0.0-beta.3",
21 | "postcss": "7",
22 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@2.0.2",
23 | "vue": "3.0.6",
24 | "vue-tippy": "6.0.0-alpha.30",
25 | "vue3-highlightjs": "1.0.5",
26 | "vuex": "4.0.2"
27 | },
28 | "devDependencies": {
29 | "@testing-library/jest-dom": "5.12.0",
30 | "@vue/cli-plugin-babel": "4.5.0",
31 | "@vue/cli-plugin-eslint": "4.5.0",
32 | "@vue/cli-plugin-unit-jest": "4.5.12",
33 | "@vue/cli-service": "4.5.0",
34 | "@vue/compiler-sfc": "3.0.0",
35 | "@vue/eslint-config-prettier": "6.0.0",
36 | "@vue/test-utils": "2.0.0-rc.6",
37 | "@vue/vue3-jest": "27.0.0-alpha.1",
38 | "babel-core": "7.0.0-bridge.0",
39 | "babel-eslint": "10.1.0",
40 | "eslint": "6.7.2",
41 | "eslint-plugin-prettier": "3.1.3",
42 | "eslint-plugin-vue": "7.0.0",
43 | "eslint-plugin-vuejs-accessibility": "0.6.2",
44 | "jest": "27.5.1",
45 | "jest-vue-preprocessor": "1.7.1",
46 | "node-sass": "5.0.0",
47 | "playwright": "1.10.0",
48 | "prettier": "1.19.1",
49 | "puppeteer": "9.0.0",
50 | "sass-loader": "10.1.1",
51 | "typescript": "3.9.3",
52 | "vue-cli-plugin-browser-extension": "0.25.1",
53 | "vue-cli-plugin-tailwind": "2.0.6",
54 | "vue-jest": "3.0.7"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extName": {
3 | "message": "headless-recorder-v2",
4 | "description": ""
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/public/browser-extension.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= htmlWebpackPlugin.options.title %>
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/favicon.ico
--------------------------------------------------------------------------------
/public/icons/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/icons/128.png
--------------------------------------------------------------------------------
/public/icons/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/icons/16.png
--------------------------------------------------------------------------------
/public/icons/19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/icons/19.png
--------------------------------------------------------------------------------
/public/icons/38.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/icons/38.png
--------------------------------------------------------------------------------
/public/icons/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/icons/48.png
--------------------------------------------------------------------------------
/public/icons/dark/clip.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/public/icons/dark/duplicate.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/icons/dark/pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/icons/dark/play.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/dark/screen.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/public/icons/dark/sync.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/light/clip.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/public/icons/light/duplicate.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/icons/light/pause.svg:
--------------------------------------------------------------------------------
1 |
8 |
14 |
18 |
--------------------------------------------------------------------------------
/public/icons/light/play.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/light/screen.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/public/icons/light/sync.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/light/zap.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/images/logo-red.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo-red.png
--------------------------------------------------------------------------------
/public/images/logo-red@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo-red@2x.png
--------------------------------------------------------------------------------
/public/images/logo-red@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo-red@3x.png
--------------------------------------------------------------------------------
/public/images/logo-yellow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo-yellow.png
--------------------------------------------------------------------------------
/public/images/logo-yellow@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo-yellow@2x.png
--------------------------------------------------------------------------------
/public/images/logo-yellow@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo-yellow@3x.png
--------------------------------------------------------------------------------
/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo.png
--------------------------------------------------------------------------------
/public/images/logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo@2x.png
--------------------------------------------------------------------------------
/public/images/logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/checkly/headless-recorder/4fb93c125f40bceb2dd9aac403e200a3ade5a8d9/public/images/logo@3x.png
--------------------------------------------------------------------------------
/src/__tests__/build.spec.js:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer'
2 | import { launchPuppeteerWithExtension } from './helpers'
3 |
4 | describe('install', () => {
5 | test('it installs the extension', async () => {
6 | const browser = await launchPuppeteerWithExtension(puppeteer)
7 | expect(browser).toBeTruthy()
8 | browser.close()
9 | }, 5000)
10 | })
11 |
--------------------------------------------------------------------------------
/src/__tests__/helpers.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { scripts } from '../../package.json'
3 | const util = require('util')
4 | const exec = util.promisify(require('child_process').exec)
5 |
6 | const extensionPath = path.join(__dirname, '../../dist')
7 |
8 | export const launchPuppeteerWithExtension = function(puppeteer) {
9 | const options = {
10 | headless: false,
11 | ignoreHTTPSErrors: true,
12 | devtools: true,
13 | args: [
14 | `--disable-extensions-except=${extensionPath}`,
15 | `--load-extension=${extensionPath}`,
16 | '--no-sandbox',
17 | '--disable-setuid-sandbox',
18 | ],
19 | }
20 |
21 | if (process.env.CI) {
22 | options.executablePath = process.env.PUPPETEER_EXEC_PATH // Set by docker on github actions
23 | }
24 |
25 | return puppeteer.launch(options)
26 | }
27 |
28 | export const runBuild = function() {
29 | return exec(scripts.build)
30 | }
31 |
--------------------------------------------------------------------------------
/src/assets/animations.css:
--------------------------------------------------------------------------------
1 | @keyframes flash {
2 | from {
3 | opacity: 0;
4 | }
5 | to {
6 | opacity: 1;
7 | }
8 | }
9 |
10 | @keyframes slideup {
11 | 0% {
12 | transform: translateY(100%);
13 | }
14 |
15 | 100% {
16 | transform: translateY(0%);
17 | }
18 | }
19 |
20 | @keyframes pop {
21 | 0% {
22 | transform: scale(1);
23 | }
24 |
25 | 0% {
26 | transform: scale(1.25);
27 | }
28 |
29 | 100% {
30 | transform: scale(1);
31 | }
32 | }
33 |
34 | @keyframes pulse {
35 | 0%,
36 | 100% {
37 | opacity: 1;
38 | }
39 | 50% {
40 | opacity: 0.5;
41 | }
42 | }
43 |
44 | .headless-recorder-flash {
45 | animation-name: flash;
46 | animation-duration: 0.5s;
47 | animation-iteration-count: 1;
48 | animation-timing-function: ease-in-out;
49 | }
50 |
51 | .headless-recorder-camera-cursor {
52 | cursor: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACMSURBVHgBzZDrDUBAEITnVEIHVIoKUAkd0MHphCXrstm4R/jBJF9yu5d9DfAXWWJT2DfFqVjDj0NGNd6QoEwVSC61RMEDKmLAzSQfHZETI8czx40cFGpQcpHMjdzkjA3Ct/r+XT5DWDkxqdzCmzmFTqi5yazW75HowWVkKTaq5X/Mg6gOD1Y814rPtQPiEFi9rPKoQQAAAABJRU5ErkJggg=='),
53 | auto !important;
54 | }
--------------------------------------------------------------------------------
/src/assets/code.css:
--------------------------------------------------------------------------------
1 | .hljs-line {
2 | color: '#ADAACC';
3 | margin-right: 8px;
4 | }
5 |
6 | /* Comment */
7 | .hljs-comment,
8 | .hljs-quote {
9 | color: #d4d0ab;
10 | }
11 |
12 | /* Red */
13 | .hljs-variable,
14 | .hljs-template-variable,
15 | .hljs-tag,
16 | .hljs-name,
17 | .hljs-selector-id,
18 | .hljs-selector-class,
19 | .hljs-regexp,
20 | .hljs-deletion {
21 | color: #C792EA;
22 | }
23 |
24 | .hljs-built_in,
25 | .hljs-builtin-name,
26 | .hljs-literal,
27 | .hljs-type,
28 | .hljs-params,
29 | .hljs-meta,
30 | .hljs-link {
31 | color: #7DD8C7;
32 | }
33 |
34 | .hljs-number {
35 | color: #FF628C;
36 | }
37 |
38 | /* Yellow */
39 | .hljs-attribute {
40 | color: #ffd700;
41 | }
42 |
43 | /* Green */
44 | .hljs-string,
45 | .hljs-symbol,
46 | .hljs-bullet,
47 | .hljs-addition {
48 | color: #ECC48D;
49 | }
50 |
51 | /* Blue */
52 | .hljs-title,
53 | .hljs-section {
54 | color: #00e0e0;
55 | }
56 |
57 | /* Purple */
58 | .hljs-keyword,
59 | .hljs-selector-tag {
60 | color: #C792EA;
61 | }
62 |
63 | .hljs {
64 | display: block;
65 | overflow-x: auto;
66 | background: #2b2b2b;
67 | color: #f8f8f2;
68 | padding: 0.5em;
69 | }
70 |
71 | .hljs-emphasis {
72 | font-style: italic;
73 | }
74 |
75 | .hljs-strong {
76 | font-weight: bold;
77 | }
78 |
79 | @media screen and (-ms-high-contrast: active) {
80 | .hljs-addition,
81 | .hljs-attribute,
82 | .hljs-built_in,
83 | .hljs-builtin-name,
84 | .hljs-bullet,
85 | .hljs-comment,
86 | .hljs-link,
87 | .hljs-literal,
88 | .hljs-meta,
89 | .hljs-number,
90 | .hljs-params,
91 | .hljs-string,
92 | .hljs-symbol,
93 | .hljs-type,
94 | .hljs-quote {
95 | color: highlight;
96 | }
97 |
98 | .hljs-keyword,
99 | .hljs-selector-tag {
100 | font-weight: bold;
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/assets/icons/gear.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/assets/icons/moon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/assets/icons/question.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/assets/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 |
3 | @tailwind components;
4 |
5 | @tailwind utilities;
6 |
--------------------------------------------------------------------------------
/src/background/index.js:
--------------------------------------------------------------------------------
1 | import badge from '@/services/badge'
2 | import browser from '@/services/browser'
3 | import storage from '@/services/storage'
4 | import { popupActions, recordingControls } from '@/services/constants'
5 | import { overlayActions } from '@/modules/overlay/constants'
6 | import { headlessActions } from '@/modules/code-generator/constants'
7 |
8 | import CodeGenerator from '@/modules/code-generator'
9 |
10 | class Background {
11 | constructor() {
12 | this._recording = []
13 | this._boundedMessageHandler = null
14 | this._boundedNavigationHandler = null
15 | this._boundedWaitHandler = null
16 |
17 | this.overlayHandler = null
18 |
19 | this._badgeState = ''
20 | this._isPaused = false
21 |
22 | this._menuId = 'PUPPETEER_RECORDER_CONTEXT_MENU'
23 | this._boundedMenuHandler = null
24 |
25 | // Some events are sent double on page navigations to simplify the event recorder.
26 | // We keep some simple state to disregard events if needed.
27 | this._hasGoto = false
28 | this._hasViewPort = false
29 | }
30 |
31 | init() {
32 | chrome.extension.onConnect.addListener(port => {
33 | port.onMessage.addListener(msg => this.handlePopupMessage(msg))
34 | })
35 | }
36 |
37 | async start() {
38 | await this.cleanUp()
39 |
40 | this._badgeState = ''
41 | this._hasGoto = false
42 | this._hasViewPort = false
43 |
44 | await browser.injectContentScript()
45 | this.toggleOverlay({ open: true, clear: true })
46 |
47 | this._boundedMessageHandler = this.handleMessage.bind(this)
48 | this._boundedNavigationHandler = this.handleNavigation.bind(this)
49 | this._boundedWaitHandler = () => badge.wait()
50 |
51 | this.overlayHandler = this.handleOverlayMessage.bind(this)
52 |
53 | // chrome.contextMenus.create({
54 | // id: this._menuId,
55 | // title: 'Headless Recorder',
56 | // contexts: ['all'],
57 | // })
58 |
59 | // chrome.contextMenus.create({
60 | // id: this._menuId + 'SELECTOR',
61 | // title: 'Copy Selector',
62 | // parentId: this._menuId,
63 | // contexts: ['all'],
64 | // })
65 |
66 | // this._boundedMenuHandler = this.handleMenuInteraction.bind(this)
67 | // chrome.contextMenus.onClicked.addListener(this._boundedMenuHandler)
68 |
69 | chrome.runtime.onMessage.addListener(this._boundedMessageHandler)
70 | chrome.runtime.onMessage.addListener(this.overlayHandler)
71 |
72 | chrome.webNavigation.onCompleted.addListener(this._boundedNavigationHandler)
73 | chrome.webNavigation.onBeforeNavigate.addListener(this._boundedWaitHandler)
74 |
75 | badge.start()
76 | }
77 |
78 | stop() {
79 | this._badgeState = this._recording.length > 0 ? '1' : ''
80 |
81 | chrome.runtime.onMessage.removeListener(this._boundedMessageHandler)
82 | chrome.webNavigation.onCompleted.removeListener(this._boundedNavigationHandler)
83 | chrome.webNavigation.onBeforeNavigate.removeListener(this._boundedWaitHandler)
84 | // chrome.contextMenus.onClicked.removeListener(this._boundedMenuHandler)
85 |
86 | badge.stop(this._badgeState)
87 |
88 | storage.set({ recording: this._recording })
89 | }
90 |
91 | pause() {
92 | badge.pause()
93 | this._isPaused = true
94 | }
95 |
96 | unPause() {
97 | badge.start()
98 | this._isPaused = false
99 | }
100 |
101 | cleanUp() {
102 | this._recording = []
103 | this._isPaused = false
104 | badge.reset()
105 |
106 | return new Promise(function(resolve) {
107 | chrome.storage.local.remove('recording', () => resolve())
108 | })
109 | }
110 |
111 | recordCurrentUrl(href) {
112 | if (!this._hasGoto) {
113 | this.handleMessage({
114 | selector: undefined,
115 | value: undefined,
116 | action: headlessActions.GOTO,
117 | href,
118 | })
119 | this._hasGoto = true
120 | }
121 | }
122 |
123 | recordCurrentViewportSize(value) {
124 | if (!this._hasViewPort) {
125 | this.handleMessage({
126 | selector: undefined,
127 | value,
128 | action: headlessActions.VIEWPORT,
129 | })
130 | this._hasViewPort = true
131 | }
132 | }
133 |
134 | recordNavigation() {
135 | this.handleMessage({
136 | selector: undefined,
137 | value: undefined,
138 | action: headlessActions.NAVIGATION,
139 | })
140 | }
141 |
142 | recordScreenshot(value) {
143 | this.handleMessage({
144 | selector: undefined,
145 | value,
146 | action: headlessActions.SCREENSHOT,
147 | })
148 | }
149 |
150 | // handleMenuInteraction(info, tab) {
151 | // }
152 |
153 | handleMessage(msg, sender) {
154 | if (msg.control) {
155 | return this.handleRecordingMessage(msg, sender)
156 | }
157 |
158 | if (msg.type === 'SIGN_CONNECT') {
159 | return
160 | }
161 |
162 | // NOTE: To account for clicks etc. we need to record the frameId
163 | // and url to later target the frame in playback
164 | msg.frameId = sender ? sender.frameId : null
165 | msg.frameUrl = sender ? sender.url : null
166 |
167 | if (!this._isPaused) {
168 | this._recording.push(msg)
169 | storage.set({ recording: this._recording })
170 | }
171 | }
172 |
173 | async handleOverlayMessage({ control }) {
174 | if (!control) {
175 | return
176 | }
177 |
178 | if (control === overlayActions.RESTART) {
179 | chrome.storage.local.set({ restart: true })
180 | chrome.storage.local.set({ clear: false })
181 | chrome.runtime.onMessage.removeListener(this.overlayHandler)
182 | this.stop()
183 | this.cleanUp()
184 | this.start()
185 | }
186 |
187 | if (control === overlayActions.CLOSE) {
188 | this.toggleOverlay()
189 | chrome.runtime.onMessage.removeListener(this.overlayHandler)
190 | }
191 |
192 | if (control === overlayActions.COPY) {
193 | const { options = {} } = await storage.get('options')
194 | const generator = new CodeGenerator(options)
195 | const code = generator.generate(this._recording)
196 |
197 | browser.sendTabMessage({
198 | action: 'CODE',
199 | value: options?.code?.showPlaywrightFirst ? code.playwright : code.puppeteer,
200 | })
201 | }
202 |
203 | if (control === overlayActions.STOP) {
204 | chrome.storage.local.set({ clear: true })
205 | chrome.storage.local.set({ pause: false })
206 | chrome.storage.local.set({ restart: false })
207 | this.stop()
208 | }
209 |
210 | if (control === overlayActions.UNPAUSE) {
211 | chrome.storage.local.set({ pause: false })
212 | this.unPause()
213 | }
214 |
215 | if (control === overlayActions.PAUSE) {
216 | chrome.storage.local.set({ pause: true })
217 | this.pause()
218 | }
219 |
220 | // TODO: the next 3 events do not need to be listened in background
221 | // content script controller, should be able to handle that directly from overlay
222 | if (control === overlayActions.CLIPPED_SCREENSHOT) {
223 | browser.sendTabMessage({ action: overlayActions.TOGGLE_SCREENSHOT_CLIPPED_MODE })
224 | }
225 |
226 | if (control === overlayActions.FULL_SCREENSHOT) {
227 | browser.sendTabMessage({ action: overlayActions.TOGGLE_SCREENSHOT_MODE })
228 | }
229 |
230 | if (control === overlayActions.ABORT_SCREENSHOT) {
231 | browser.sendTabMessage({ action: overlayActions.CLOSE_SCREENSHOT_MODE })
232 | }
233 | }
234 |
235 | handleRecordingMessage({ control, href, value, coordinates }) {
236 | if (control === recordingControls.EVENT_RECORDER_STARTED) {
237 | badge.setText(this._badgeState)
238 | }
239 |
240 | if (control === recordingControls.GET_VIEWPORT_SIZE) {
241 | this.recordCurrentViewportSize(coordinates)
242 | }
243 |
244 | if (control === recordingControls.GET_CURRENT_URL) {
245 | this.recordCurrentUrl(href)
246 | }
247 |
248 | if (control === recordingControls.GET_SCREENSHOT) {
249 | this.recordScreenshot(value)
250 | }
251 | }
252 |
253 | handlePopupMessage(msg) {
254 | if (!msg.action) {
255 | return
256 | }
257 |
258 | if (msg.action === popupActions.START) {
259 | this.start()
260 | }
261 |
262 | if (msg.action === popupActions.STOP) {
263 | browser.sendTabMessage({ action: popupActions.STOP })
264 | this.stop()
265 | }
266 |
267 | if (msg.action === popupActions.CLEAN_UP) {
268 | chrome.runtime.onMessage.removeListener(this.overlayHandler)
269 | msg.value && this.stop()
270 | this.toggleOverlay()
271 | this.cleanUp()
272 | }
273 |
274 | if (msg.action === popupActions.PAUSE) {
275 | if (!msg.stop) {
276 | browser.sendTabMessage({ action: popupActions.PAUSE })
277 | }
278 | this.pause()
279 | }
280 |
281 | if (msg.action === popupActions.UN_PAUSE) {
282 | if (!msg.stop) {
283 | browser.sendTabMessage({ action: popupActions.UN_PAUSE })
284 | }
285 | this.unPause()
286 | }
287 | }
288 |
289 | async handleNavigation({ frameId }) {
290 | await browser.injectContentScript()
291 | this.toggleOverlay({ open: true, pause: this._isPaused })
292 |
293 | if (frameId === 0) {
294 | this.recordNavigation()
295 | }
296 | }
297 |
298 | // TODO: Use a better naming convention for this arguments
299 | toggleOverlay({ open = false, clear = false, pause = false } = {}) {
300 | browser.sendTabMessage({ action: overlayActions.TOGGLE_OVERLAY, value: { open, clear, pause } })
301 | }
302 | }
303 |
304 | window.headlessRecorder = new Background()
305 | window.headlessRecorder.init()
306 |
--------------------------------------------------------------------------------
/src/components/Button.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
14 |
15 |
24 |
--------------------------------------------------------------------------------
/src/components/Footer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Version {{ version }}
7 |
8 |
9 |
10 |
24 |
--------------------------------------------------------------------------------
/src/components/Header.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Headless Recorder
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/components/RecordingLabel.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 | {{ text }}
8 |
9 |
10 |
11 |
25 |
--------------------------------------------------------------------------------
/src/components/RoundButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
19 |
20 |
32 |
--------------------------------------------------------------------------------
/src/components/Toggle.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
43 |
--------------------------------------------------------------------------------
/src/components/__tests__/RecordingTab.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils'
2 | import RecordingTab from '../RecordingTab'
3 |
4 | describe('RecordingTab.vue', () => {
5 | test('it has the correct pristine / empty state', () => {
6 | const wrapper = mount(RecordingTab)
7 | expect(wrapper.element).toMatchSnapshot()
8 | })
9 |
10 | test('it has the correct waiting for events state', () => {
11 | const wrapper = mount(RecordingTab, { props: { isRecording: true } })
12 | expect(wrapper.element).toMatchSnapshot()
13 | expect(wrapper.find('.event-list').element).toBeEmpty()
14 | })
15 |
16 | test('it has the correct recording Puppeteer custom events state', () => {
17 | const wrapper = mount(RecordingTab, {
18 | props: {
19 | isRecording: true,
20 | liveEvents: [
21 | {
22 | action: 'goto*',
23 | href: 'http://example.com',
24 | },
25 | {
26 | action: 'viewport*',
27 | selector: undefined,
28 | value: { width: 1280, height: 800 },
29 | },
30 | {
31 | action: 'navigation*',
32 | selector: undefined,
33 | },
34 | ],
35 | },
36 | })
37 | expect(wrapper.element).toMatchSnapshot()
38 | expect(wrapper.find('.event-list').element).not.toBeEmpty()
39 | })
40 |
41 | test('it has the correct recording DOM events state', () => {
42 | const wrapper = mount(RecordingTab, {
43 | props: {
44 | isRecording: true,
45 | liveEvents: [
46 | {
47 | action: 'click',
48 | selector: '.main > a.link',
49 | href: 'http://example.com',
50 | },
51 | ],
52 | },
53 | })
54 | expect(wrapper.element).toMatchSnapshot()
55 | expect(wrapper.find('.event-list').element).not.toBeEmpty()
56 | })
57 | })
58 |
--------------------------------------------------------------------------------
/src/components/__tests__/ResultsTab.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils'
2 | import VueHighlightJS from 'vue3-highlightjs'
3 |
4 | import ResultsTab from '../ResultsTab'
5 |
6 | describe('RecordingTab.vue', () => {
7 | test('it has the correct pristine / empty state', () => {
8 | const wrapper = mount(ResultsTab)
9 | expect(wrapper.element).toMatchSnapshot()
10 | expect(wrapper.find('code.javascript').exists()).toBe(false)
11 | })
12 |
13 | test('it show a code box when there is code', () => {
14 | const wrapper = mount(ResultsTab, {
15 | global: {
16 | plugins: [VueHighlightJS],
17 | },
18 | props: { puppeteer: `await page.click('.class')` },
19 | })
20 | expect(wrapper.element).toMatchSnapshot()
21 | expect(wrapper.find('code.javascript').exists()).toBe(true)
22 | })
23 |
24 | test('it render tabs for puppeteer & playwright', () => {
25 | const wrapper = mount(ResultsTab)
26 | expect(wrapper.findAll('.tabs__action').length).toEqual(2)
27 | })
28 |
29 | test('it render playwright first when option is present', async () => {
30 | const wrapper = await mount(ResultsTab, {
31 | props: {
32 | options: {
33 | code: {
34 | showPlaywrightFirst: true,
35 | },
36 | },
37 | },
38 | })
39 | expect(wrapper.find('.tabs__action').text()).toEqual('🎭playwright')
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/RecordingTab.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`RecordingTab.vue it has the correct pristine / empty state 1`] = `
4 |
7 |
10 |
13 |
18 |
19 | No recorded events yet
20 |
21 |
24 | Click record to begin
25 |
26 |
40 |
41 |
45 |
48 | Waiting for events
49 |
50 |
56 |
57 |
58 |
59 | `;
60 |
61 | exports[`RecordingTab.vue it has the correct recording DOM events state 1`] = `
62 |
65 |
68 |
72 |
77 |
78 | No recorded events yet
79 |
80 |
83 | Click record to begin
84 |
85 |
100 |
101 |
104 |
108 | Waiting for events
109 |
110 |
113 |
114 |
117 |
120 | 1.
121 |
122 |
125 |
128 | click
129 |
130 |
133 | .main > a.link
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 | `;
143 |
144 | exports[`RecordingTab.vue it has the correct recording Puppeteer custom events state 1`] = `
145 |
148 |
151 |
155 |
160 |
161 | No recorded events yet
162 |
163 |
166 | Click record to begin
167 |
168 |
183 |
184 |
187 |
191 | Waiting for events
192 |
193 |
196 |
197 |
200 |
203 | 1.
204 |
205 |
208 |
211 | goto*
212 |
213 |
216 | http://example.com
217 |
218 |
219 |
220 |
223 |
226 | 2.
227 |
228 |
231 |
234 | viewport*
235 |
236 |
239 | width: 1280, height: 800
240 |
241 |
242 |
243 |
246 |
249 | 3.
250 |
251 |
254 |
257 | navigation*
258 |
259 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 | `;
270 |
271 | exports[`RecordingTab.vue it has the correct waiting for events state 1`] = `
272 |
275 |
278 |
282 |
287 |
288 | No recorded events yet
289 |
290 |
293 | Click record to begin
294 |
295 |
310 |
311 |
314 |
317 | Waiting for events
318 |
319 |
325 |
326 |
327 |
328 | `;
329 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/ResultsTab.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`RecordingTab.vue it has the correct pristine / empty state 1`] = `
4 |
7 |
10 |
11 |
14 |
15 |
19 |
22 | puppeteer
23 |
24 |
25 |
28 |
29 | 🎭
30 |
31 |
32 |
35 | playwright
36 |
37 |
38 |
39 |
40 |
43 |
44 |
45 |
46 | No code yet...
47 |
48 |
49 |
50 |
51 |
52 |
53 | `;
54 |
55 | exports[`RecordingTab.vue it show a code box when there is code 1`] = `
56 |
59 |
62 |
63 |
66 |
67 |
71 |
74 | puppeteer
75 |
76 |
77 |
80 |
81 | 🎭
82 |
83 |
84 |
87 | playwright
88 |
89 |
90 |
91 |
92 |
95 |
96 |
97 |
100 |
103 | await
104 |
105 | page.click(
106 |
109 | '.class'
110 |
111 | )
112 |
113 |
114 |
115 |
116 |
117 |
118 | `;
119 |
--------------------------------------------------------------------------------
/src/content-scripts/__tests__/attributes.spec.js:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer'
2 | import { launchPuppeteerWithExtension } from '@/__tests__/helpers'
3 | import { waitForAndGetEvents, cleanEventLog, startServer } from './helpers'
4 |
5 | let server
6 | let port
7 | let browser
8 | let page
9 |
10 | describe('attributes', () => {
11 | beforeAll(async done => {
12 | const buildDir = '../../../dist'
13 | const fixture = './fixtures/attributes.html'
14 | {
15 | const { server: _s, port: _p } = await startServer(buildDir, fixture)
16 | server = _s
17 | port = _p
18 | }
19 | return done()
20 | }, 20000)
21 |
22 | afterAll(done => {
23 | server.close(() => {
24 | return done()
25 | })
26 | })
27 |
28 | beforeEach(async () => {
29 | browser = await launchPuppeteerWithExtension(puppeteer)
30 | page = await browser.newPage()
31 | await page.goto(`http://localhost:${port}/`)
32 | await cleanEventLog(page)
33 | })
34 |
35 | afterEach(async () => {
36 | browser.close()
37 | })
38 |
39 | test('it should load the content', async () => {
40 | const content = await page.$('#content-root')
41 | expect(content).toBeTruthy()
42 | })
43 |
44 | test('it should use data attributes throughout selector', async () => {
45 | await page.evaluate('window.eventRecorder._dataAttribute = "data-qa"')
46 | await page.click('span')
47 |
48 | const event = (await waitForAndGetEvents(page, 1))[0]
49 | expect(event.selector).toEqual(
50 | 'body > #content-root > [data-qa="article-wrapper"] > [data-qa="article-body"] > span'
51 | )
52 | })
53 |
54 | test('it should use data attributes throughout selector even when id is set', async () => {
55 | await page.evaluate('window.eventRecorder._dataAttribute = "data-qa"')
56 | await page.click('#link')
57 |
58 | const event = (await waitForAndGetEvents(page, 1))[0]
59 | expect(event.selector).toEqual('[data-qa="link"]')
60 | })
61 |
62 | test('it should use id throughout selector when data attributes is not set', async () => {
63 | await page.evaluate('window.eventRecorder._dataAttribute = null')
64 | await page.click('#link')
65 |
66 | const event = (await waitForAndGetEvents(page, 1))[0]
67 | expect(event.selector).toEqual('#link')
68 | })
69 | })
70 |
--------------------------------------------------------------------------------
/src/content-scripts/__tests__/fixtures/attributes.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | forms
6 |
7 |
8 |
9 |
10 |
Lorem
11 |
12 | Read More...
13 |
14 |
15 |
Click here
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/content-scripts/__tests__/fixtures/forms.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | forms
6 |
7 |
8 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/content-scripts/__tests__/forms.spec.js:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer'
2 | import _ from 'lodash'
3 | import { launchPuppeteerWithExtension } from '@/__tests__/helpers'
4 | import { waitForAndGetEvents, cleanEventLog, startServer } from './helpers'
5 |
6 | let server
7 | let port
8 | let browser
9 | let page
10 |
11 | describe('forms', () => {
12 | beforeAll(async done => {
13 | const buildDir = '../../../dist'
14 | const fixture = './fixtures/forms.html'
15 | {
16 | const { server: _s, port: _p } = await startServer(buildDir, fixture)
17 | server = _s
18 | port = _p
19 | }
20 | return done()
21 | }, 20000)
22 |
23 | afterAll(done => {
24 | server.close(() => {
25 | return done()
26 | })
27 | })
28 |
29 | beforeEach(async () => {
30 | browser = await launchPuppeteerWithExtension(puppeteer)
31 | page = await browser.newPage()
32 | await page.goto(`http://localhost:${port}/`)
33 | await cleanEventLog(page)
34 | })
35 |
36 | afterEach(async () => {
37 | browser.close()
38 | })
39 |
40 | const tab = 1
41 | const change = 1
42 | test('it should load the form', async () => {
43 | const form = await page.$('form')
44 | expect(form).toBeTruthy()
45 | })
46 |
47 | test('it should record text input elements', async () => {
48 | const string = 'I like turtles'
49 | await page.type('input[type="text"]', string)
50 | await page.keyboard.press('Tab')
51 |
52 | const eventLog = await waitForAndGetEvents(page, string.length + tab + change)
53 | const event = _.find(eventLog, e => {
54 | return e.action === 'keydown' && e.keyCode === 9
55 | })
56 | expect(event.value).toEqual(string)
57 | })
58 |
59 | test('it should record textarea elements', async () => {
60 | const string = 'I like turtles\n but also cats'
61 | await page.type('textarea', string)
62 | await page.keyboard.press('Tab')
63 |
64 | const eventLog = await waitForAndGetEvents(page, string.length + tab + change)
65 | const event = _.find(eventLog, e => {
66 | return e.action === 'keydown' && e.keyCode === 9
67 | })
68 | expect(event.value).toEqual(string)
69 | })
70 |
71 | test('it should record radio input elements', async () => {
72 | await page.click('#radioChoice1')
73 | await page.click('#radioChoice3')
74 | const eventLog = await waitForAndGetEvents(page, 2 + 2 * change)
75 | expect(eventLog[0].value).toEqual('radioChoice1')
76 | expect(eventLog[2].value).toEqual('radioChoice3')
77 | })
78 |
79 | test('it should record select and option elements', async () => {
80 | await page.select('select', 'hamster')
81 | const eventLog = await waitForAndGetEvents(page, 1)
82 | expect(eventLog[0].value).toEqual('hamster')
83 | expect(eventLog[0].tagName).toEqual('SELECT')
84 | })
85 |
86 | test('it should record checkbox input elements', async () => {
87 | await page.click('#checkbox1')
88 | await page.click('#checkbox2')
89 | const eventLog = await waitForAndGetEvents(page, 2 + 2 * change)
90 | expect(eventLog[0].value).toEqual('checkbox1')
91 | expect(eventLog[2].value).toEqual('checkbox2')
92 | })
93 | })
94 |
--------------------------------------------------------------------------------
/src/content-scripts/__tests__/helpers.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import path from 'path'
3 |
4 | export const waitForAndGetEvents = async function(page, amount) {
5 | await waitForRecorderEvents(page, amount)
6 | return getEventLog(page)
7 | }
8 |
9 | export const waitForRecorderEvents = function(page, amount) {
10 | return page.waitForFunction(`window.eventRecorder._getEventLog().length >= ${amount || 1}`)
11 | }
12 |
13 | export const getEventLog = function(page) {
14 | return page.evaluate(() => {
15 | return window.eventRecorder._getEventLog()
16 | })
17 | }
18 |
19 | export const cleanEventLog = function(page) {
20 | return page.evaluate(() => {
21 | return window.eventRecorder._clearEventLog()
22 | })
23 | }
24 |
25 | export const startServer = function(buildDir, file) {
26 | return new Promise(resolve => {
27 | const app = express()
28 | app.use('/build', express.static(path.join(__dirname, buildDir)))
29 | app.get('/', (req, res) => {
30 | res.status(200).sendFile(file, { root: __dirname })
31 | })
32 | let server
33 | let port
34 | const retry = e => {
35 | if (e.code === 'EADDRINUSE') {
36 | setTimeout(() => connect, 1000)
37 | }
38 | }
39 | const connect = () => {
40 | port = 0 | (Math.random() * 1000 + 3000)
41 | server = app.listen(port)
42 | server.once('error', retry)
43 | server.once('listening', () => {
44 | return resolve({ server, port })
45 | })
46 | }
47 | connect()
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/src/content-scripts/__tests__/screenshot-controller.spec.js:
--------------------------------------------------------------------------------
1 | import UIController from '../shooter'
2 |
3 | // this test NEEDS to come first because of shitty JSDOM.
4 | // See https://github.com/facebook/jest/issues/1224
5 | it('Registers mouse events', () => {
6 | jest.useFakeTimers()
7 |
8 | document.body.innerHTML =
9 | '' + '
UserName
' + '
' + '
'
10 |
11 | const uic = new UIController()
12 | uic.showSelector()
13 |
14 | const handleClick = jest.fn()
15 | uic.on('click', handleClick)
16 |
17 | const el = document.querySelector('#username')
18 | el.click()
19 |
20 | jest.runAllTimers()
21 |
22 | expect(setTimeout).toHaveBeenCalledTimes(1)
23 | expect(handleClick).toHaveBeenCalled()
24 | })
25 |
26 | it('Shows and hides the selector', () => {
27 | const uic = new UIController()
28 |
29 | uic.showSelector()
30 | let overlay = document.querySelector('.headlessRecorderOverlay')
31 | let outline = document.querySelector('.headlessRecorderOutline')
32 |
33 | expect(overlay).toBeDefined()
34 | expect(outline).toBeDefined()
35 |
36 | uic.hideSelector()
37 | overlay = document.querySelector('.headlessRecorderOverlay')
38 | outline = document.querySelector('.headlessRecorderOutline')
39 |
40 | expect(overlay).toBeNull()
41 | expect(outline).toBeNull()
42 | })
43 |
--------------------------------------------------------------------------------
/src/content-scripts/controller.js:
--------------------------------------------------------------------------------
1 | import { overlayActions } from '@/modules/overlay/constants'
2 | import { popupActions, recordingControls, isDarkMode } from '@/services/constants'
3 |
4 | import storage from '@/services/storage'
5 | import browser from '@/services/browser'
6 |
7 | import Shooter from '@/modules/shooter'
8 |
9 | export default class HeadlessController {
10 | constructor({ overlay, recorder, store }) {
11 | this.backgroundListener = null
12 |
13 | this.store = store
14 | this.shooter = null
15 | this.overlay = overlay
16 | this.recorder = recorder
17 | }
18 |
19 | async init() {
20 | const { options } = await storage.get(['options'])
21 |
22 | const darkMode = options && options.extension ? options.extension.darkMode : isDarkMode()
23 | const { dataAttribute } = options ? options.code : {}
24 |
25 | this.store.commit('setDarkMode', darkMode)
26 | this.store.commit('setDataAttribute', dataAttribute)
27 |
28 | this.recorder.init(() => this.listenBackgroundMessages())
29 | }
30 |
31 | listenBackgroundMessages() {
32 | this.backgroundListener = this.backgroundListener || this.handleBackgroundMessages.bind(this)
33 | chrome.runtime.onMessage.addListener(this.backgroundListener)
34 | }
35 |
36 | async handleBackgroundMessages(msg) {
37 | if (!msg?.action) {
38 | return
39 | }
40 |
41 | switch (msg.action) {
42 | case overlayActions.TOGGLE_SCREENSHOT_MODE:
43 | this.handleScreenshot(false)
44 | break
45 |
46 | case overlayActions.TOGGLE_SCREENSHOT_CLIPPED_MODE:
47 | this.handleScreenshot(true)
48 | break
49 |
50 | case overlayActions.CLOSE_SCREENSHOT_MODE:
51 | this.cancelScreenshot()
52 | break
53 |
54 | case overlayActions.TOGGLE_OVERLAY:
55 | msg?.value?.open ? this.overlay.mount(msg.value) : this.overlay.unmount()
56 | break
57 |
58 | case popupActions.STOP:
59 | this.store.commit('close')
60 | break
61 |
62 | case popupActions.PAUSE:
63 | this.store.commit('pause')
64 | break
65 |
66 | case popupActions.UN_PAUSE:
67 | this.store.commit('unpause')
68 | break
69 |
70 | case 'CODE':
71 | await browser.copyToClipboard(msg.value)
72 | this.store.commit('showCopy')
73 | break
74 | }
75 | }
76 |
77 | handleScreenshot(isClipped) {
78 | this.recorder.disableClickRecording()
79 | this.shooter = new Shooter({ isClipped, store: this.store })
80 |
81 | this.shooter.addCameraIcon()
82 |
83 | this.store.state.screenshotMode
84 | ? this.shooter.startScreenshotMode()
85 | : this.shooter.stopScreenshotMode()
86 |
87 | this.shooter.on('click', ({ selector }) => {
88 | this.store.commit('stopScreenshotMode')
89 |
90 | this.shooter.showScreenshotEffect()
91 | this.recorder._sendMessage({ control: recordingControls.GET_SCREENSHOT, value: selector })
92 | this.recorder.enableClickRecording()
93 | })
94 | }
95 |
96 | cancelScreenshot() {
97 | if (!this.store.state.screenshotMode) {
98 | return
99 | }
100 |
101 | this.store.commit('stopScreenshotMode')
102 | this.recorder.enableClickRecording()
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/content-scripts/index.js:
--------------------------------------------------------------------------------
1 | import store from '@/store'
2 |
3 | import Overlay from '@/modules/overlay'
4 | import Recorder from '@/modules/recorder'
5 |
6 | import HeadlessController from '@/content-scripts/controller'
7 |
8 | window.headlessRecorder = new HeadlessController({
9 | overlay: new Overlay({ store }),
10 | recorder: new Recorder({ store }),
11 | store,
12 | })
13 |
14 | window.headlessRecorder.init()
15 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Headless Recorder",
3 | "version": "1.0.0",
4 | "manifest_version": 2,
5 | "description": "A Chrome extension for recording browser interaction and generating Puppeteer & Playwright scripts",
6 | "default_locale": "en",
7 | "permissions": [
8 | "storage",
9 | "webNavigation",
10 | "activeTab",
11 | "cookies",
12 | "*://*/"
13 | ],
14 | "icons" : {
15 | "16": "icons/16.png",
16 | "48": "icons/48.png",
17 | "128": "icons/128.png"
18 | },
19 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
20 | "background": {
21 | "scripts": [
22 | "js/background.js"
23 | ],
24 | "persistent": false
25 | },
26 | "browser_action": {
27 | "default_popup": "popup.html",
28 | "default_title": "__MSG_extName__",
29 | "default_icon": {
30 | "19": "icons/19.png",
31 | "38": "icons/38.png"
32 | }
33 | },
34 | "options_ui": {
35 | "page": "options.html",
36 | "browser_style": true,
37 | "open_in_tab": true
38 | },
39 | "web_accessible_resources": [
40 | "icons/dark/play.svg",
41 | "icons/light/play.svg",
42 | "icons/dark/pause.svg",
43 | "icons/light/pause.svg",
44 | "icons/dark/screen.svg",
45 | "icons/light/screen.svg",
46 | "icons/dark/clip.svg",
47 | "icons/light/clip.svg",
48 | "icons/dark/sync.svg",
49 | "icons/light/sync.svg",
50 | "icons/dark/duplicate.svg",
51 | "icons/light/duplicate.svg"
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/src/modules/code-generator/__tests__/playwright-code-generator.spec.js:
--------------------------------------------------------------------------------
1 | import PlaywrightCodeGenerator from '../playwright'
2 |
3 | describe('PlaywrightCodeGenerator', () => {
4 | test('it should generate nothing when there are no events', () => {
5 | const events = []
6 | const codeGenerator = new PlaywrightCodeGenerator()
7 | expect(codeGenerator._parseEvents(events)).toBeFalsy()
8 | })
9 |
10 | test('it generates a page.selectOption() only for select dropdowns', () => {
11 | const events = [
12 | {
13 | action: 'change',
14 | selector: 'select#animals',
15 | tagName: 'SELECT',
16 | value: 'hamster',
17 | },
18 | ]
19 | const codeGenerator = new PlaywrightCodeGenerator()
20 | expect(codeGenerator._parseEvents(events)).toContain(
21 | `await page.selectOption('${events[0].selector}', '${events[0].value}')`
22 | )
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/src/modules/code-generator/__tests__/puppeteer-code-generator.spec.js:
--------------------------------------------------------------------------------
1 | import PuppeteerCodeGenerator from '../puppeteer'
2 | import { headlessActions } from '@/services/constants'
3 |
4 | describe('PuppeteerCodeGenerator', () => {
5 | test('it should generate nothing when there are no events', () => {
6 | const events = []
7 | const codeGenerator = new PuppeteerCodeGenerator()
8 | expect(codeGenerator._parseEvents(events)).toBeFalsy()
9 | })
10 |
11 | test('it generates a page.select() only for select dropdowns', () => {
12 | const events = [
13 | {
14 | action: 'change',
15 | selector: 'select#animals',
16 | tagName: 'SELECT',
17 | value: 'hamster',
18 | },
19 | ]
20 | const codeGenerator = new PuppeteerCodeGenerator()
21 | expect(codeGenerator._parseEvents(events)).toContain(
22 | "await page.select('select#animals', 'hamster')"
23 | )
24 | })
25 |
26 | test('it generates the correct waitForNavigation code', () => {
27 | const events = [{ action: 'click', selector: 'a.link' }, { action: headlessActions.NAVIGATION }]
28 | const codeGenerator = new PuppeteerCodeGenerator()
29 | const code = codeGenerator._parseEvents(events)
30 | const lines = code.split('\n')
31 | expect(lines[1].trim()).toEqual('const navigationPromise = page.waitForNavigation()')
32 | expect(lines[4].trim()).toEqual("await page.click('a.link')")
33 | expect(lines[6].trim()).toEqual('await navigationPromise')
34 | })
35 |
36 | test('it does not generate waitForNavigation code when turned off', () => {
37 | const events = [{ action: 'navigation*' }, { action: 'click', selector: 'a.link' }]
38 | const codeGenerator = new PuppeteerCodeGenerator({
39 | waitForNavigation: false,
40 | })
41 | expect(codeGenerator._parseEvents(events)).not.toContain(
42 | 'const navigationPromise = page.waitForNavigation()\n'
43 | )
44 | expect(codeGenerator._parseEvents(events)).not.toContain('await navigationPromise\n')
45 | })
46 |
47 | test('it generates the correct waitForSelector code before clicks', () => {
48 | const events = [{ action: 'click', selector: 'a.link' }]
49 | const codeGenerator = new PuppeteerCodeGenerator()
50 | const result = codeGenerator._parseEvents(events)
51 |
52 | expect(result).toContain("await page.waitForSelector('a.link')")
53 | expect(result).toContain("await page.click('a.link')")
54 | })
55 |
56 | test('it does not generate the waitForSelector code before clicks when turned off', () => {
57 | const events = [{ action: 'click', selector: 'a.link' }]
58 | const codeGenerator = new PuppeteerCodeGenerator({
59 | waitForSelectorOnClick: false,
60 | })
61 | const result = codeGenerator._parseEvents(events)
62 |
63 | expect(result).not.toContain("await page.waitForSelector('a.link')")
64 | expect(result).toContain("await page.click('a.link')")
65 | })
66 |
67 | test('it uses the default page frame when events originate from frame 0', () => {
68 | const events = [
69 | {
70 | action: 'click',
71 | selector: 'a.link',
72 | frameId: 0,
73 | frameUrl: 'https://some.site.com',
74 | },
75 | ]
76 | const codeGenerator = new PuppeteerCodeGenerator()
77 | const result = codeGenerator._parseEvents(events)
78 | expect(result).toContain("await page.click('a.link')")
79 | })
80 |
81 | test('it uses a different frame when events originate from an iframe', () => {
82 | const events = [
83 | {
84 | action: 'click',
85 | selector: 'a.link',
86 | frameId: 123,
87 | frameUrl: 'https://some.iframe.com',
88 | },
89 | ]
90 | const codeGenerator = new PuppeteerCodeGenerator()
91 | const result = codeGenerator._parseEvents(events)
92 | expect(result).toContain("await frame_123.click('a.link')")
93 | })
94 |
95 | test('it adds a frame selection preamble when events originate from an iframe', () => {
96 | const events = [
97 | {
98 | action: 'click',
99 | selector: 'a.link',
100 | frameId: 123,
101 | frameUrl: 'https://some.iframe.com',
102 | },
103 | ]
104 | const codeGenerator = new PuppeteerCodeGenerator()
105 | const result = codeGenerator._parseEvents(events)
106 | expect(result).toContain('let frames = await page.frames()')
107 | expect(result).toContain(
108 | "const frame_123 = frames.find(f => f.url() === 'https://some.iframe.com'"
109 | )
110 | })
111 |
112 | test('it generates the correct current page screenshot code', () => {
113 | const events = [{ action: headlessActions.SCREENSHOT }]
114 | const codeGenerator = new PuppeteerCodeGenerator()
115 | const result = codeGenerator._parseEvents(events)
116 |
117 | expect(result).toContain("await page.screenshot({ path: 'screenshot_1.png' })")
118 | })
119 |
120 | test('it generates the correct clipped page screenshot code', () => {
121 | const events = [
122 | {
123 | action: headlessActions.SCREENSHOT,
124 | value: { x: '10px', y: '300px', width: '800px', height: '600px' },
125 | },
126 | ]
127 | const codeGenerator = new PuppeteerCodeGenerator()
128 | const result = codeGenerator._parseEvents(events)
129 |
130 | expect(result).toContain(
131 | "await page.screenshot({ path: 'screenshot_1.png', clip: { x: 10, y: 300, width: 800, height: 600 } })"
132 | )
133 | })
134 |
135 | test('it generates the correct escaped value', () => {
136 | const events = [
137 | {
138 | action: 'keydown',
139 | keyCode: 9,
140 | selector: 'input.value',
141 | value: "hello');console.log('world",
142 | },
143 | ]
144 | const codeGenerator = new PuppeteerCodeGenerator()
145 | const result = codeGenerator._parseEvents(events)
146 |
147 | expect(result).toContain("await page.type('input.value', 'hello\\');console.log(\\'world')")
148 | })
149 |
150 | test('it generates the correct escaped value with backslash', () => {
151 | const events = [{ action: 'click', selector: 'button.\\hello\\' }]
152 | const codeGenerator = new PuppeteerCodeGenerator()
153 | const result = codeGenerator._parseEvents(events)
154 |
155 | expect(result).toContain("await page.click('button.\\\\hello\\\\')")
156 | })
157 | })
158 |
--------------------------------------------------------------------------------
/src/modules/code-generator/base-generator.js:
--------------------------------------------------------------------------------
1 | import Block from '@/modules/code-generator/block'
2 | import { headlessActions, eventsToRecord } from '@/modules/code-generator/constants'
3 |
4 | export const defaults = {
5 | wrapAsync: false,
6 | headless: true,
7 | waitForNavigation: true,
8 | waitForSelectorOnClick: true,
9 | blankLinesBetweenBlocks: true,
10 | dataAttribute: '',
11 | showPlaywrightFirst: true,
12 | keyCode: 9,
13 | }
14 |
15 | export default class BaseGenerator {
16 | constructor(options) {
17 | this._options = Object.assign(defaults, options)
18 | this._blocks = []
19 | this._frame = 'page'
20 | this._frameId = 0
21 | this._allFrames = {}
22 | this._screenshotCounter = 0
23 |
24 | this._hasNavigation = false
25 | }
26 |
27 | generate() {
28 | throw new Error('Not implemented.')
29 | }
30 |
31 | _getHeader() {
32 | let hdr = this._options.wrapAsync ? this._wrappedHeader : this._header
33 | hdr = this._options.headless ? hdr : hdr?.replace('launch()', 'launch({ headless: false })')
34 | return hdr
35 | }
36 |
37 | _getFooter() {
38 | return this._options.wrapAsync ? this._wrappedFooter : this._footer
39 | }
40 |
41 | _parseEvents(events) {
42 | let result = ''
43 |
44 | if (!events) return result
45 |
46 | for (let i = 0; i < events.length; i++) {
47 | const { action, selector, value, href, keyCode, tagName, frameId, frameUrl } = events[i]
48 | const escapedSelector = selector ? selector?.replace(/\\/g, '\\\\') : selector
49 |
50 | // we need to keep a handle on what frames events originate from
51 | this._setFrames(frameId, frameUrl)
52 |
53 | switch (action) {
54 | case 'keydown':
55 | if (keyCode === this._options.keyCode) {
56 | this._blocks.push(this._handleKeyDown(escapedSelector, value, keyCode))
57 | }
58 | break
59 | case 'click':
60 | this._blocks.push(this._handleClick(escapedSelector, events))
61 | break
62 | case 'change':
63 | if (tagName === 'SELECT') {
64 | this._blocks.push(this._handleChange(escapedSelector, value))
65 | }
66 | break
67 | case headlessActions.GOTO:
68 | this._blocks.push(this._handleGoto(href, frameId))
69 | break
70 | case headlessActions.VIEWPORT:
71 | this._blocks.push(this._handleViewport(value.width, value.height))
72 | break
73 | case headlessActions.NAVIGATION:
74 | this._blocks.push(this._handleWaitForNavigation())
75 | this._hasNavigation = true
76 | break
77 | case headlessActions.SCREENSHOT:
78 | this._blocks.push(this._handleScreenshot(value))
79 | break
80 | }
81 | }
82 |
83 | if (this._hasNavigation && this._options.waitForNavigation) {
84 | const block = new Block(this._frameId, {
85 | type: headlessActions.NAVIGATION_PROMISE,
86 | value: 'const navigationPromise = page.waitForNavigation()',
87 | })
88 | this._blocks.unshift(block)
89 | }
90 |
91 | this._postProcess()
92 |
93 | const indent = this._options.wrapAsync ? ' ' : ''
94 | const newLine = `\n`
95 |
96 | for (let block of this._blocks) {
97 | const lines = block.getLines()
98 | for (let line of lines) {
99 | result += indent + line.value + newLine
100 | }
101 | }
102 |
103 | return result
104 | }
105 |
106 | _setFrames(frameId, frameUrl) {
107 | if (frameId && frameId !== 0) {
108 | this._frameId = frameId
109 | this._frame = `frame_${frameId}`
110 | this._allFrames[frameId] = frameUrl
111 | } else {
112 | this._frameId = 0
113 | this._frame = 'page'
114 | }
115 | }
116 |
117 | _postProcess() {
118 | // when events are recorded from different frames, we want to add a frame setter near the code that uses that frame
119 | if (Object.keys(this._allFrames).length > 0) {
120 | this._postProcessSetFrames()
121 | }
122 |
123 | if (this._options.blankLinesBetweenBlocks && this._blocks.length > 0) {
124 | this._postProcessAddBlankLines()
125 | }
126 | }
127 |
128 | _handleKeyDown(selector, value) {
129 | const block = new Block(this._frameId)
130 | block.addLine({
131 | type: eventsToRecord.KEYDOWN,
132 | value: `await ${this._frame}.type('${selector}', '${this._escapeUserInput(value)}')`,
133 | })
134 | return block
135 | }
136 |
137 | _handleClick(selector) {
138 | const block = new Block(this._frameId)
139 | if (this._options.waitForSelectorOnClick) {
140 | block.addLine({
141 | type: eventsToRecord.CLICK,
142 | value: `await ${this._frame}.waitForSelector('${selector}')`,
143 | })
144 | }
145 | block.addLine({
146 | type: eventsToRecord.CLICK,
147 | value: `await ${this._frame}.click('${selector}')`,
148 | })
149 | return block
150 | }
151 |
152 | _handleChange(selector, value) {
153 | return new Block(this._frameId, {
154 | type: eventsToRecord.CHANGE,
155 | value: `await ${this._frame}.select('${selector}', '${value}')`,
156 | })
157 | }
158 |
159 | _handleGoto(href) {
160 | return new Block(this._frameId, {
161 | type: headlessActions.GOTO,
162 | value: `await ${this._frame}.goto('${href}')`,
163 | })
164 | }
165 |
166 | _handleViewport() {
167 | throw new Error('Not implemented.')
168 | }
169 |
170 | _handleScreenshot(value) {
171 | this._screenshotCounter += 1
172 |
173 | if (value) {
174 | return new Block(this._frameId, {
175 | type: headlessActions.SCREENSHOT,
176 | value: `const element${this._screenshotCounter} = await page.$('${value}')
177 | await element${this._screenshotCounter}.screenshot({ path: 'screenshot_${this._screenshotCounter}.png' })`,
178 | })
179 | }
180 |
181 | return new Block(this._frameId, {
182 | type: headlessActions.SCREENSHOT,
183 | value: `await ${this._frame}.screenshot({ path: 'screenshot_${this._screenshotCounter}.png', fullPage: true })`,
184 | })
185 | }
186 |
187 | _handleWaitForNavigation() {
188 | const block = new Block(this._frameId)
189 | if (this._options.waitForNavigation) {
190 | block.addLine({
191 | type: headlessActions.NAVIGATION,
192 | value: `await navigationPromise`,
193 | })
194 | }
195 | return block
196 | }
197 |
198 | _postProcessSetFrames() {
199 | for (let [i, block] of this._blocks.entries()) {
200 | const lines = block.getLines()
201 | for (let line of lines) {
202 | if (line.frameId && Object.keys(this._allFrames).includes(line.frameId.toString())) {
203 | const declaration = `const frame_${line.frameId} = frames.find(f => f.url() === '${
204 | this._allFrames[line.frameId]
205 | }')`
206 | this._blocks[i].addLineToTop({
207 | type: headlessActions.FRAME_SET,
208 | value: declaration,
209 | })
210 | this._blocks[i].addLineToTop({
211 | type: headlessActions.FRAME_SET,
212 | value: 'let frames = await page.frames()',
213 | })
214 | delete this._allFrames[line.frameId]
215 | break
216 | }
217 | }
218 | }
219 | }
220 |
221 | _postProcessAddBlankLines() {
222 | let i = 0
223 | while (i <= this._blocks.length) {
224 | const blankLine = new Block()
225 | blankLine.addLine({ type: null, value: '' })
226 | this._blocks.splice(i, 0, blankLine)
227 | i += 2
228 | }
229 | }
230 |
231 | _escapeUserInput(value) {
232 | return value?.replace(/\\/g, '\\\\')?.replace(/'/g, "\\'")
233 | }
234 | }
235 |
--------------------------------------------------------------------------------
/src/modules/code-generator/block.js:
--------------------------------------------------------------------------------
1 | export default class Block {
2 | constructor(frameId, line) {
3 | this._lines = []
4 | this._frameId = frameId
5 |
6 | if (line) {
7 | line.frameId = this._frameId
8 | this._lines.push(line)
9 | }
10 | }
11 |
12 | addLineToTop(line) {
13 | line.frameId = this._frameId
14 | this._lines.unshift(line)
15 | }
16 |
17 | addLine(line) {
18 | line.frameId = this._frameId
19 | this._lines.push(line)
20 | }
21 |
22 | getLines() {
23 | return this._lines
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/modules/code-generator/constants.js:
--------------------------------------------------------------------------------
1 | export const headlessActions = {
2 | GOTO: 'GOTO',
3 | VIEWPORT: 'VIEWPORT',
4 | WAITFORSELECTOR: 'WAITFORSELECTOR',
5 | NAVIGATION: 'NAVIGATION',
6 | NAVIGATION_PROMISE: 'NAVIGATION_PROMISE',
7 | FRAME_SET: 'FRAME_SET',
8 | SCREENSHOT: 'SCREENSHOT',
9 | }
10 |
11 | export const eventsToRecord = {
12 | CLICK: 'click',
13 | DBLCLICK: 'dblclick',
14 | CHANGE: 'change',
15 | KEYDOWN: 'keydown',
16 | SELECT: 'select',
17 | SUBMIT: 'submit',
18 | LOAD: 'load',
19 | UNLOAD: 'unload',
20 | }
21 |
22 | export const headlessTypes = {
23 | PUPPETEER: 'puppeteer',
24 | PLAYWRIGHT: 'playwright',
25 | }
26 |
--------------------------------------------------------------------------------
/src/modules/code-generator/index.js:
--------------------------------------------------------------------------------
1 | import PuppeteerCodeGenerator from '@/modules/code-generator/puppeteer'
2 | import PlaywrightCodeGenerator from '@/modules/code-generator/playwright'
3 |
4 | export default class CodeGenerator {
5 | constructor(options = {}) {
6 | this.puppeteerGenerator = new PuppeteerCodeGenerator(options)
7 | this.playwrightGenerator = new PlaywrightCodeGenerator(options)
8 | }
9 |
10 | generate(recording) {
11 | return {
12 | puppeteer: this.puppeteerGenerator.generate(recording),
13 | playwright: this.playwrightGenerator.generate(recording),
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/modules/code-generator/playwright.js:
--------------------------------------------------------------------------------
1 | import Block from '@/modules/code-generator/block'
2 | import { headlessActions } from '@/modules/code-generator/constants'
3 | import BaseGenerator from '@/modules/code-generator/base-generator'
4 |
5 | const importPlaywright = `const { chromium } = require('playwright');\n`
6 |
7 | const header = `const browser = await chromium.launch()
8 | const page = await browser.newPage()`
9 |
10 | const footer = `await browser.close()`
11 |
12 | const wrappedHeader = `(async () => {
13 | ${header}\n`
14 |
15 | const wrappedFooter = ` ${footer}
16 | })()`
17 |
18 | export default class PlaywrightCodeGenerator extends BaseGenerator {
19 | constructor(options) {
20 | super(options)
21 | this._header = header
22 | this._footer = footer
23 | this._wrappedHeader = wrappedHeader
24 | this._wrappedFooter = wrappedFooter
25 | }
26 |
27 | generate(events) {
28 | return importPlaywright + this._getHeader() + this._parseEvents(events) + this._getFooter()
29 | }
30 |
31 | _handleViewport(width, height) {
32 | return new Block(this._frameId, {
33 | type: headlessActions.VIEWPORT,
34 | value: `await ${this._frame}.setViewportSize({ width: ${width}, height: ${height} })`,
35 | })
36 | }
37 |
38 | _handleChange(selector, value) {
39 | return new Block(this._frameId, {
40 | type: headlessActions.CHANGE,
41 | value: `await ${this._frame}.selectOption('${selector}', '${value}')`,
42 | })
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/modules/code-generator/puppeteer.js:
--------------------------------------------------------------------------------
1 | import Block from '@/modules/code-generator/block'
2 | import { headlessActions } from '@/modules/code-generator/constants'
3 | import BaseGenerator from '@/modules/code-generator/base-generator'
4 |
5 | const importPuppeteer = `const puppeteer = require('puppeteer');\n`
6 |
7 | const header = `const browser = await puppeteer.launch()
8 | const page = await browser.newPage()`
9 |
10 | const footer = `await browser.close()`
11 |
12 | const wrappedHeader = `(async () => {
13 | const browser = await puppeteer.launch()
14 | const page = await browser.newPage()\n`
15 |
16 | const wrappedFooter = ` await browser.close()
17 | })()`
18 |
19 | export default class PuppeteerCodeGenerator extends BaseGenerator {
20 | constructor(options) {
21 | super(options)
22 | this._header = header
23 | this._footer = footer
24 | this._wrappedHeader = wrappedHeader
25 | this._wrappedFooter = wrappedFooter
26 | }
27 |
28 | generate(events) {
29 | return importPuppeteer + this._getHeader() + this._parseEvents(events) + this._getFooter()
30 | }
31 |
32 | _handleViewport(width, height) {
33 | return new Block(this._frameId, {
34 | type: headlessActions.VIEWPORT,
35 | value: `await ${this._frame}.setViewport({ width: ${width}, height: ${height} })`,
36 | })
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/modules/overlay/Overlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
Recording finished!
13 |
You can copy the code to clipboard right away!
14 |
15 |
16 |
17 |
24 | Copy to clipboard
25 | Copied!
26 |
27 |
28 |
29 | Restart Recording
30 |
31 |
32 | ×
33 |
34 |
35 |
36 |
37 |
38 |
39 | REC
40 |
41 |
42 | alt + k to hide
43 |
44 |
50 |
51 |
52 |
58 |
59 |
60 |
61 |
62 |
68 |
69 |
70 |
76 |
77 |
78 |
79 |
80 | {{ currentSelector }}
81 |
82 |
83 |
84 |
85 |
86 |
166 |
167 |
424 |
--------------------------------------------------------------------------------
/src/modules/overlay/Selector.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
89 |
90 |
112 |
--------------------------------------------------------------------------------
/src/modules/overlay/constants.js:
--------------------------------------------------------------------------------
1 | export const overlaySelectors = {
2 | OVERLAY_ID: 'headless-recorder-overlay',
3 | SELECTOR_ID: 'headless-recorder-selector',
4 | CURRENT_SELECTOR_CLASS: 'headless-recorder-selected-element',
5 | CURSOR_CAMERA_CLASS: 'headless-recorder-camera-cursor',
6 | FLASH_CLASS: 'headless-recorder-flash',
7 | }
8 |
9 | export const overlayActions = {
10 | COPY: 'COPY',
11 | STOP: 'STOP',
12 | CLOSE: 'CLOSE',
13 | PAUSE: 'PAUSE',
14 | UNPAUSE: 'UNPAUSE',
15 | RESTART: 'RESTART',
16 | FULL_SCREENSHOT: 'FULL_SCREENSHOT',
17 | CLIPPED_SCREENSHOT: 'CLIPPED_SCREENSHOT',
18 | ABORT_SCREENSHOT: 'ABORT_SCREENSHOT',
19 |
20 | TOGGLE_SCREENSHOT_MODE: 'TOGGLE_SCREENSHOT_MODE',
21 | TOGGLE_SCREENSHOT_CLIPPED_MODE: 'TOGGLE_SCREENSHOT_CLIPPED_MODE',
22 | CLOSE_SCREENSHOT_MODE: 'CLOSE_SCREENSHOT_MODE',
23 | TOGGLE_OVERLAY: 'TOGGLE_OVERLAY',
24 | }
25 |
--------------------------------------------------------------------------------
/src/modules/overlay/index.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 |
3 | import getSelector from '@/services/selector'
4 | import SelectorApp from '@/modules/overlay/Selector.vue'
5 | import OverlayApp from '@/modules/overlay/Overlay.vue'
6 | import { overlaySelectors } from '@/modules/overlay/constants'
7 |
8 | export default class Overlay {
9 | constructor({ store }) {
10 | this.overlayApp = null
11 | this.selectorApp = null
12 |
13 | this.overlayContainer = null
14 | this.selectorContainer = null
15 |
16 | this.mouseOverEvent = null
17 | this.scrollEvent = null
18 | this.isScrolling = false
19 |
20 | this.store = store
21 | }
22 |
23 | mount({ clear = false, pause = false } = {}) {
24 | if (this.overlayContainer) {
25 | return
26 | }
27 |
28 | this.overlayContainer = document.createElement('div')
29 | this.overlayContainer.id = overlaySelectors.OVERLAY_ID
30 | document.body.appendChild(this.overlayContainer)
31 |
32 | this.selectorContainer = document.createElement('div')
33 | this.selectorContainer.id = overlaySelectors.SELECTOR_ID
34 | document.body.appendChild(this.selectorContainer)
35 |
36 | if (clear) {
37 | this.store.commit('clear')
38 | }
39 | if (pause) {
40 | this.store.commit('pause')
41 | }
42 |
43 | this.selectorApp = createApp(SelectorApp)
44 | .use(this.store)
45 | .mount('#' + overlaySelectors.SELECTOR_ID)
46 |
47 | this.overlayApp = createApp(OverlayApp)
48 | .use(this.store)
49 | .mount('#' + overlaySelectors.OVERLAY_ID)
50 |
51 | this.mouseOverEvent = e => {
52 | const selector = getSelector(e, { dataAttribute: this.store.state.dataAttribute })
53 | this.overlayApp.currentSelector = selector.includes('#' + overlaySelectors.OVERLAY_ID)
54 | ? ''
55 | : selector
56 |
57 | if (
58 | this.overlayApp.currentSelector &&
59 | (!this.store.state.screenshotMode || this.store.state.screenshotClippedMode)
60 | ) {
61 | this.selectorApp.move(e, [overlaySelectors.OVERLAY_ID])
62 | }
63 | }
64 |
65 | // Hide selector while the user is scrolling
66 | this.scrollEvent = () => {
67 | this.selectorApp.scrolling = true
68 | window.clearTimeout(this.isScrolling)
69 | this.isScrolling = setTimeout(() => (this.selectorApp.scrolling = false), 66)
70 | }
71 |
72 | window.document.addEventListener('mouseover', this.mouseOverEvent)
73 | window.addEventListener('scroll', this.scrollEvent, false)
74 | }
75 |
76 | unmount() {
77 | if (!this.overlayContainer) {
78 | return
79 | }
80 |
81 | document.body.removeChild(this.overlayContainer)
82 | document.body.removeChild(this.selectorContainer)
83 |
84 | this.overlayContainer = null
85 | this.overlayApp = null
86 | this.selectorContainer = null
87 | this.selectorApp = null
88 |
89 | window.document.removeEventListener('mouseover', this.mouseOverEvent)
90 | window.removeEventListener('scroll', this.scrollEvent, false)
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/modules/recorder/index.js:
--------------------------------------------------------------------------------
1 | import getSelector from '@/services/selector'
2 | import { recordingControls } from '@/services/constants'
3 | import { overlaySelectors } from '@/modules/overlay/constants'
4 | import { eventsToRecord } from '@/modules/code-generator/constants'
5 |
6 | export default class Recorder {
7 | constructor({ store }) {
8 | // this._boundedMessageListener = null
9 | this._eventLog = []
10 | this._previousEvent = null
11 |
12 | this._isTopFrame = window.location === window.parent.location
13 | this._isRecordingClicks = true
14 |
15 | this.store = store
16 | }
17 |
18 | init(cb) {
19 | const events = Object.values(eventsToRecord)
20 |
21 | if (!window.pptRecorderAddedControlListeners) {
22 | this._addAllListeners(events)
23 | cb && cb()
24 | window.pptRecorderAddedControlListeners = true
25 | }
26 |
27 | if (!window.document.pptRecorderAddedControlListeners && chrome.runtime?.onMessage) {
28 | window.document.pptRecorderAddedControlListeners = true
29 | }
30 |
31 | if (this._isTopFrame) {
32 | this._sendMessage({ control: recordingControls.EVENT_RECORDER_STARTED })
33 | this._sendMessage({ control: recordingControls.GET_CURRENT_URL, href: window.location.href })
34 | this._sendMessage({
35 | control: recordingControls.GET_VIEWPORT_SIZE,
36 | coordinates: { width: window.innerWidth, height: window.innerHeight },
37 | })
38 | }
39 | }
40 |
41 | _addAllListeners(events) {
42 | const boundedRecordEvent = this._recordEvent.bind(this)
43 | events.forEach(type => window.addEventListener(type, boundedRecordEvent, true))
44 | }
45 |
46 | _sendMessage(msg) {
47 | // filter messages based on enabled / disabled features
48 | if (msg.action === 'click' && !this._isRecordingClicks) {
49 | return
50 | }
51 |
52 | try {
53 | chrome.runtime && chrome?.runtime?.onMessage
54 | ? chrome.runtime.sendMessage(msg)
55 | : this._eventLog.push(msg)
56 | } catch (err) {
57 | console.debug('caught error', err)
58 | }
59 | }
60 |
61 | _recordEvent(e) {
62 | if (this._previousEvent && this._previousEvent.timeStamp === e.timeStamp) {
63 | return
64 | }
65 | this._previousEvent = e
66 |
67 | // we explicitly catch any errors and swallow them, as none node-type events are also ingested.
68 | // for these events we cannot generate selectors, which is OK
69 | try {
70 | const selector = getSelector(e, { dataAttribute: this.store.state.dataAttribute })
71 |
72 | if (selector.includes('#' + overlaySelectors.OVERLAY_ID)) {
73 | return
74 | }
75 |
76 | this.store.commit('showRecorded')
77 |
78 | this._sendMessage({
79 | selector,
80 | value: e.target.value,
81 | tagName: e.target.tagName,
82 | action: e.type,
83 | keyCode: e.keyCode ? e.keyCode : null,
84 | href: e.target.href ? e.target.href : null,
85 | coordinates: Recorder._getCoordinates(e),
86 | })
87 | } catch (err) {
88 | console.error(err)
89 | }
90 | }
91 |
92 | _getEventLog() {
93 | return this._eventLog
94 | }
95 |
96 | _clearEventLog() {
97 | this._eventLog = []
98 | }
99 |
100 | disableClickRecording() {
101 | this._isRecordingClicks = false
102 | }
103 |
104 | enableClickRecording() {
105 | this._isRecordingClicks = true
106 | }
107 |
108 | static _getCoordinates(evt) {
109 | const eventsWithCoordinates = {
110 | mouseup: true,
111 | mousedown: true,
112 | mousemove: true,
113 | mouseover: true,
114 | }
115 |
116 | return eventsWithCoordinates[evt.type] ? { x: evt.clientX, y: evt.clientY } : null
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/modules/shooter/index.js:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'events'
2 | import getSelector from '@/services/selector'
3 | import { overlayActions, overlaySelectors } from '@/modules/overlay/constants'
4 |
5 | const BORDER_THICKNESS = 2
6 | class Shooter extends EventEmitter {
7 | constructor({ isClipped = false, store } = {}) {
8 | super()
9 |
10 | this.store = store
11 | this.isClipped = isClipped
12 |
13 | this._overlay = null
14 | this._selector = null
15 | this._element = null
16 | this._dimensions = {}
17 | this.currentSelctor = ''
18 |
19 | this._boundeMouseMove = this.mousemove.bind(this)
20 | this._boundeMouseOver = this.mouseover.bind(this)
21 | this._boundeMouseUp = this.mouseup.bind(this)
22 | this._boundedKeyUp = this.keyup.bind(this)
23 | }
24 |
25 | mouseover(e) {
26 | this.currentSelctor = getSelector(e, { dataAttribute: this.store.state.dataAttribute }).replace(
27 | '.' + overlaySelectors.CURSOR_CAMERA_CLASS,
28 | 'body'
29 | )
30 | }
31 |
32 | startScreenshotMode() {
33 | if (!this._overlay) {
34 | this._overlay = window.document.createElement('div')
35 | this._overlay.id = 'headless-recorder-shooter'
36 | this._overlay.style.position = 'fixed'
37 | this._overlay.style.top = '0px'
38 | this._overlay.style.left = '0px'
39 | this._overlay.style.width = '100%'
40 | this._overlay.style.height = '100%'
41 | this._overlay.style.pointerEvents = 'none'
42 | this._overlay.style.zIndex = 2147483646
43 |
44 | if (this.isClipped) {
45 | this._selector = window.document.createElement('div')
46 | this._selector.id = 'headless-recorder-shooter-outline'
47 | this._selector.style.position = 'fixed'
48 | this._overlay.appendChild(this._selector)
49 | } else {
50 | this._overlay.style.border = `${BORDER_THICKNESS}px dashed rgba(255, 73, 73, 0.7)`
51 | this._overlay.style.background = 'rgba(255, 73, 73, 0.1)'
52 | }
53 | }
54 |
55 | if (!this._overlay.parentNode) {
56 | window.document.body.appendChild(this._overlay)
57 |
58 | window.document.body.addEventListener('mousemove', this._boundeMouseMove, false)
59 | window.document.body.addEventListener('click', this._boundeMouseUp, false)
60 | window.document.body.addEventListener('keyup', this._boundedKeyUp, false)
61 | window.document.addEventListener('mouseover', this._boundeMouseOver, false)
62 | }
63 | }
64 |
65 | stopScreenshotMode() {
66 | if (this._overlay) {
67 | window.document.body.removeChild(this._overlay)
68 | }
69 | this._overlay = this._selector = this._element = null
70 | this._dimensions = {}
71 | }
72 |
73 | showScreenshotEffect() {
74 | window.document.body.classList.add(overlaySelectors.FLASH_CLASS)
75 | window.document.body.classList.remove(overlaySelectors.CURSOR_CAMERA_CLASS)
76 | setTimeout(() => window.document.body.classList.remove(overlaySelectors.FLASH_CLASS), 1000)
77 | }
78 |
79 | addCameraIcon() {
80 | window.document.body.classList.add(overlaySelectors.CURSOR_CAMERA_CLASS)
81 | }
82 |
83 | removeCameraIcon() {
84 | window.document.body.classList.remove(overlaySelectors.CURSOR_CAMERA_CLASS)
85 | }
86 |
87 | mousemove(e) {
88 | if (this._element !== e.target) {
89 | this._element = e.target
90 |
91 | this._dimensions.top = -window.scrollY
92 | this._dimensions.left = -window.scrollX
93 |
94 | let elem = e.target
95 |
96 | while (elem && elem !== window.document.body) {
97 | this._dimensions.top += elem.offsetTop
98 | this._dimensions.left += elem.offsetLeft
99 | elem = elem.offsetParent
100 | }
101 | this._dimensions.width = this._element.offsetWidth
102 | this._dimensions.height = this._element.offsetHeight
103 |
104 | if (this._selector) {
105 | this._selector.style.top = this._dimensions.top - BORDER_THICKNESS + 'px'
106 | this._selector.style.left = this._dimensions.left - BORDER_THICKNESS + 'px'
107 | this._selector.style.width = this._dimensions.width + 'px'
108 | this._selector.style.height = this._dimensions.height + 'px'
109 | }
110 | }
111 | }
112 |
113 | mouseup(e) {
114 | setTimeout(() => {
115 | this.cleanup()
116 | const payload = { raw: e }
117 |
118 | if (this.isClipped) {
119 | payload.selector = this.currentSelctor
120 | }
121 |
122 | this.emit('click', payload)
123 | }, 100)
124 | }
125 |
126 | keyup(e) {
127 | if (e.code !== 'Escape') {
128 | return
129 | }
130 |
131 | this.cleanup()
132 | this.removeCameraIcon()
133 | chrome.runtime.sendMessage({ control: overlayActions.ABORT_SCREENSHOT })
134 | }
135 |
136 | cleanup() {
137 | window.document.body.removeEventListener('mousemove', this._boundeMouseMove, false)
138 | window.document.body.removeEventListener('click', this._boundeMouseUp, false)
139 | window.document.body.removeEventListener('keyup', this._boundedKeyUp, false)
140 | window.document.removeEventListener('mouseover', this._boundeMouseOver, false)
141 |
142 | window.document.body.removeChild(this._overlay)
143 | this._overlay = null
144 | }
145 | }
146 |
147 | export default Shooter
148 |
--------------------------------------------------------------------------------
/src/options/OptionsApp.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
15 |
16 |
30 |
31 |
32 | Recorder
33 | Custom data attribute
34 |
35 |
43 |
44 | Define an attribute that we'll attempt to use when selecting the elements, i.e
45 | "data-custom". This is handy when React or Vue based apps generate random class names.
46 |
47 |
48 | 🚨
49 | When "custom data attribute" is set, it will take
51 | precedence from over any other selector (even ID)
52 |
53 |
54 |
55 |
56 |
Set key code
57 |
58 |
59 | {{ recordingKeyCodePress ? 'Capturing...' : 'Record Key Stroke' }}
60 |
61 |
62 | {{ options.code.keyCode }}
63 |
64 |
65 |
66 | What key will be used for capturing input changes. The value here is the key code. This
67 | will not handle multiple keys.
68 |
69 |
70 |
71 |
72 |
73 | Generator
74 |
75 | Wrap code in async function
76 |
77 |
78 | Set headless
in playwright/puppeteer launch options
79 |
80 |
81 | Add waitForNavigation
lines on navigation
82 |
83 |
84 | Add waitForSelector
lines before every
85 | page.click()
86 |
87 |
88 | Add blank lines between code blocks
89 |
90 |
91 | Show Playwright tab first
92 |
93 |
94 |
95 |
96 | Extension
97 |
98 | Use Dark Mode {{ options.extension.darkMode }}
99 |
100 |
101 | Allow recording of usage telemetry
102 |
103 |
104 | We only record clicks for basic product development, no website content or input data.
105 | Data is never, ever shared with 3rd parties.
106 |
107 |
108 |
109 |
110 |
111 |
112 |
206 |
207 |
242 |
--------------------------------------------------------------------------------
/src/options/__tests__/App.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils'
2 | import App from '../OptionsApp'
3 |
4 | function createChromeLocalStorageMock(options) {
5 | let ops = options || {}
6 | return {
7 | options,
8 | storage: {
9 | local: {
10 | get: (key, cb) => {
11 | return cb(ops)
12 | },
13 | set: (options, cb) => {
14 | ops = options
15 | cb()
16 | },
17 | },
18 | },
19 | }
20 | }
21 |
22 | describe('App.vue', () => {
23 | beforeEach(() => {
24 | window.chrome = null
25 | })
26 |
27 | test('it has the correct pristine / empty state', () => {
28 | window.chrome = createChromeLocalStorageMock()
29 | const wrapper = mount(App)
30 | expect(wrapper.element).toMatchSnapshot()
31 | })
32 |
33 | test('it loads the default options', () => {
34 | window.chrome = createChromeLocalStorageMock()
35 | const wrapper = mount(App)
36 | expect(wrapper.vm.$data.options.code.wrapAsync).toBeTruthy()
37 | })
38 |
39 | test('it has the default key code for capturing inputs as 9 (Tab)', () => {
40 | window.chrome = createChromeLocalStorageMock()
41 | const wrapper = mount(App)
42 | expect(wrapper.vm.$data.options.code.keyCode).toBe(9)
43 | })
44 |
45 | test('clicking the button will listen for the next keydown and update the key code option', () => {
46 | const options = { code: { keyCode: 9 } }
47 | window.chrome = createChromeLocalStorageMock(options)
48 | const wrapper = mount(App)
49 |
50 | return wrapper.vm
51 | .$nextTick()
52 | .then(() => {
53 | wrapper.find('button').element.click()
54 | const event = new KeyboardEvent('keydown', { keyCode: 16 })
55 | window.dispatchEvent(event)
56 | return wrapper.vm.$nextTick()
57 | })
58 | .then(() => {
59 | expect(wrapper.vm.$data.options.code.keyCode).toBe(16)
60 | })
61 | })
62 |
63 | test("it stores and loads the user's edited options", () => {
64 | const options = { code: { wrapAsync: true } }
65 | window.chrome = createChromeLocalStorageMock(options)
66 | const wrapper = mount(App)
67 |
68 | return wrapper.vm
69 | .$nextTick()
70 | .then(() => {
71 | const checkBox = wrapper.find('#options-code-wrapAsync')
72 | checkBox.trigger('click')
73 | expect(wrapper.find('.saving-badge').text()).toEqual('Saving...')
74 | return wrapper.vm.$nextTick()
75 | })
76 | .then(() => {
77 | // we need to simulate a page reload
78 | wrapper.vm.load()
79 | return wrapper.vm.$nextTick()
80 | })
81 | .then(() => {
82 | const checkBox = wrapper.find('#options-code-wrapAsync')
83 | return expect(checkBox.element.checked).toBeFalsy()
84 | })
85 | })
86 | })
87 |
--------------------------------------------------------------------------------
/src/options/__tests__/__snapshots__/App.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`App.vue it has the correct pristine / empty state 1`] = `
4 |
7 |
10 |
21 |
22 |
36 |
37 |
38 | `;
39 |
--------------------------------------------------------------------------------
/src/options/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './OptionsApp.vue'
3 |
4 | import '@/assets/tailwind.css'
5 |
6 | createApp(App).mount('#app')
7 |
--------------------------------------------------------------------------------
/src/popup/PopupApp.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 |
17 |
24 |
25 |
26 |
31 |
32 |
33 | Restart
34 |
35 |
36 |
42 | Copy to clipboard
43 | Copied!
44 |
45 |
46 |
47 | Run on Checkly
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
275 |
276 |
291 |
--------------------------------------------------------------------------------
/src/popup/__tests__/App.spec.js:
--------------------------------------------------------------------------------
1 | import { shallowMount } from '@vue/test-utils'
2 | import App from '../PopupApp'
3 |
4 | const chrome = {
5 | storage: {
6 | local: {
7 | get: jest.fn(),
8 | },
9 | },
10 | extension: {
11 | connect: jest.fn(),
12 | },
13 | }
14 |
15 | describe('App.vue', () => {
16 | test('it has the correct pristine / empty state', () => {
17 | window.chrome = chrome
18 | const wrapper = shallowMount(App)
19 | expect(wrapper.element).toMatchSnapshot()
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/src/popup/__tests__/__snapshots__/App.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`App.vue it has the correct pristine / empty state 1`] = `
4 |
8 |
53 |
56 |
59 |
64 |
86 |
87 |
104 |
105 |
108 |
109 |
110 | `;
111 |
--------------------------------------------------------------------------------
/src/popup/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import VueHighlightJS from 'vue3-highlightjs'
3 |
4 | import '@/assets/code.css'
5 | import '@/assets/tailwind.css'
6 |
7 | import App from './PopupApp.vue'
8 |
9 | createApp(App)
10 | .use(VueHighlightJS)
11 | .mount('#app')
12 |
--------------------------------------------------------------------------------
/src/services/__tests__/analytics.spec.js:
--------------------------------------------------------------------------------
1 | import analytics from '../analytics'
2 |
3 | Object.defineProperty(window, '_gaq', {
4 | writable: true,
5 | value: {
6 | push: jest.fn(),
7 | },
8 | })
9 |
10 | describe('trackPageView', () => {
11 | beforeEach(() => {
12 | window._gaq.push.mockClear()
13 | })
14 |
15 | it('has telemetry enabled', () => {
16 | const options = {
17 | extension: {
18 | telemetry: true,
19 | },
20 | }
21 |
22 | analytics.trackPageView(options)
23 | expect(window._gaq.push.mock.calls.length).toBe(1)
24 | })
25 |
26 | it('does not have telemetry enabled', () => {
27 | const options = {
28 | extension: {
29 | telemetry: false,
30 | },
31 | }
32 |
33 | analytics.trackPageView(options)
34 | expect(window._gaq.push.mock.calls.length).toBe(0)
35 | })
36 | })
--------------------------------------------------------------------------------
/src/services/__tests__/badge.spec.js:
--------------------------------------------------------------------------------
1 | import badge from '../badge'
2 |
3 | global.chrome = {
4 | browserAction: {
5 | setIcon: jest.fn(),
6 | setBadgeText: jest.fn(text => (inputText.data = text)),
7 | setBadgeBackgroundColor: jest.fn(),
8 | },
9 | }
10 |
11 | const inputText = {
12 | data: '',
13 | }
14 |
15 | beforeEach(() => {
16 | chrome.browserAction.setIcon.mockClear()
17 | chrome.browserAction.setBadgeBackgroundColor.mockClear()
18 | })
19 |
20 | describe('start', () => {
21 | it('sets recording icon', () => {
22 | badge.start()
23 | expect(chrome.browserAction.setIcon.mock.calls.length).toBe(1)
24 | })
25 | })
26 |
27 | describe('pause', () => {
28 | it('sets pause icon', () => {
29 | badge.pause()
30 | expect(chrome.browserAction.setIcon.mock.calls.length).toBe(1)
31 | })
32 | })
33 |
34 | describe('setText', () => {
35 | it('sets selected text on the badge', () => {
36 | badge.setText('data')
37 | expect(inputText.data.text).toBe('data')
38 | })
39 | })
40 |
41 | describe('reset', () => {
42 | it('reset text to empty string', () => {
43 | badge.reset()
44 | badge.setText('')
45 | expect(inputText.data.text).toBe('')
46 | })
47 | })
48 |
49 | describe('wait', () => {
50 | it('changes text to wait', () => {
51 | badge.wait()
52 | badge.setText('wait')
53 | expect(chrome.browserAction.setBadgeBackgroundColor.mock.calls.length).toBe(1)
54 | expect(inputText.data.text).toBe('wait')
55 | })
56 | })
57 |
58 | describe('stop', () => {
59 | it('stops recording and sets result text', () => {
60 | badge.stop('data')
61 | expect(chrome.browserAction.setIcon.mock.calls.length).toBe(1)
62 | expect(chrome.browserAction.setBadgeBackgroundColor.mock.calls.length).toBe(1)
63 | expect(inputText.data.text).toBe('data')
64 | })
65 | })
66 |
--------------------------------------------------------------------------------
/src/services/__tests__/browser.spec.js:
--------------------------------------------------------------------------------
1 | import browser from '../browser'
2 |
3 | const activeTab = { id: 1, active: true }
4 |
5 | const copyText = {
6 | data: '',
7 | }
8 |
9 | const cookies = [
10 | {
11 | name: 'checkly'
12 | }
13 | ]
14 |
15 |
16 | window.chrome = {
17 | tabs: {
18 | create: jest.fn(),
19 | query: jest.fn((options, cb) => (cb([activeTab]))),
20 | executeScript: jest.fn((options, cb) => (cb(options))),
21 | sendMessage: jest.fn(),
22 | },
23 | extension: {
24 | connect: jest.fn(),
25 | },
26 | runtime: {
27 | openOptionsPage: jest.fn()
28 | },
29 | cookies: {
30 | getAll: jest.fn((options, cb) => (cb(cookies)))
31 | }}
32 |
33 | global.navigator.permissions = {
34 | query: jest
35 | .fn()
36 | .mockImplementationOnce(() => Promise.resolve({ state: 'granted' })),
37 | };
38 |
39 | global.navigator.clipboard = {
40 | writeText: jest.fn(text => (copyText.data = text))
41 | };
42 |
43 | beforeEach(() => {
44 | window?.chrome?.tabs.create.mockClear()
45 | window?.chrome?.extension.connect.mockClear()
46 | window?.chrome?.runtime.openOptionsPage.mockClear()
47 | window?.chrome?.tabs.query.mockClear()
48 | })
49 |
50 | describe('getActiveTab', () => {
51 | it('returns the active tab', async () => {
52 | const activeTab = await browser.getActiveTab()
53 | expect(activeTab).toBe(activeTab)
54 | expect(window.chrome.tabs.query.mock.calls.length).toBe(1)
55 | })
56 | })
57 |
58 | describe('copyToClipboard', () => {
59 | it('copies text to clipboard', async () => {
60 | await browser.copyToClipboard('data')
61 | expect(window.navigator.clipboard.writeText.mock.calls.length).toBe(1)
62 | })
63 | })
64 |
65 | describe('injectContentScript', () => {
66 | it('executes content script', async () => {
67 | await browser.injectContentScript()
68 | expect(window.chrome.tabs.executeScript.mock.calls.length).toBe(1)
69 | })
70 | })
71 |
72 | describe('getChecklyCookie', () => {
73 | it('returns checkly cookie', async () => {
74 | await browser.getChecklyCookie()
75 | expect(window.chrome.cookies.getAll.mock.calls.length).toBe(1)
76 | })
77 | })
78 |
79 | describe('openChecklyRunner', () => {
80 | it('is not logged in', () => {
81 | browser.openChecklyRunner({code: 1, runner: 2, isLoggedIn: false})
82 | expect(window.chrome.tabs.create.mock.calls.length).toBe(1)
83 | })
84 |
85 | it('is logged in', () => {
86 | browser.openChecklyRunner({code: 1, runner: 2, isLoggedIn: true})
87 | expect(window.chrome.tabs.create.mock.calls.length).toBe(1)
88 | })
89 | })
90 |
91 | describe('getBackgroundBus', () => {
92 | it('gets backgorund bus', async () => {
93 | browser.getBackgroundBus()
94 | expect(window.chrome.extension.connect.mock.calls.length).toBe(1)
95 | })
96 | })
97 |
98 | describe('openOptionsPage', () => {
99 | it('calls function that opens options page', async () => {
100 | browser.openOptionsPage()
101 | expect(window.chrome.runtime.openOptionsPage.mock.calls.length).toBe(1)
102 | })
103 | })
104 |
105 | describe('openHelpPage', () => {
106 | it('calls function that creates new tab and opens help page', async () => {
107 | browser.openHelpPage()
108 | expect(window.chrome.tabs.create.mock.calls.length).toBe(1)
109 | })
110 | })
111 |
112 |
113 |
--------------------------------------------------------------------------------
/src/services/__tests__/constants.spec.js:
--------------------------------------------------------------------------------
1 | import { isDarkMode } from '../constants'
2 |
3 | function setMatchMediaMock(matches) {
4 | Object.defineProperty(window, 'matchMedia', {
5 | writable: true,
6 | value: jest.fn(() => ({ matches })),
7 | })
8 | }
9 |
10 | describe('isDarkMode()', () => {
11 | beforeEach(() => {
12 | window?.matchMedia?.mockClear()
13 | })
14 |
15 | it('has darkMode enabled', () => {
16 | setMatchMediaMock(true)
17 | expect(isDarkMode()).toBe(true)
18 | })
19 |
20 | it('has darkMode disabled', () => {
21 | setMatchMediaMock(false)
22 | expect(isDarkMode()).toBe(false)
23 | })
24 |
25 | it('does not have matchMedia browser API', () => {
26 | window.matchMedia = null
27 | expect(isDarkMode()).toBe(false)
28 | })
29 | })
30 |
--------------------------------------------------------------------------------
/src/services/__tests__/storage.spec.js:
--------------------------------------------------------------------------------
1 | import storage from '../storage'
2 |
3 | const store = {
4 | token: 'xxx',
5 | name: 'lionel',
6 | }
7 |
8 | beforeEach(() => {
9 | window.chrome = {
10 | storage: {
11 | local: {
12 | get: jest.fn((keys, cb) => {
13 | if (typeof keys === 'string') {
14 | return cb(store[keys])
15 | }
16 |
17 | const results = []
18 | if (Array.isArray(keys)) {
19 | keys.forEach(key => {
20 | results.push(store[key])
21 | })
22 |
23 | return cb(results)
24 | }
25 | }),
26 | remove: jest.fn((keys, cb) => {
27 | delete store[keys];
28 | return cb(store)
29 | }),
30 | set: jest.fn((props, cb) => {
31 | const newStore = { ...store, ...props }
32 | return cb(newStore)
33 | }),
34 | },
35 | },
36 | }
37 |
38 | window.chrome.storage.local.get.mockClear()
39 | window.chrome.storage.local.set.mockClear()
40 | window.chrome.storage.local.remove.mockClear()
41 | })
42 |
43 | describe('get', () => {
44 | it('return a single value', async () => {
45 | const token = await storage.get('token')
46 | expect(token).toBe(store.token)
47 | expect(window.chrome.storage.local.get.mock.calls.length).toBe(1)
48 | })
49 |
50 | it('return multiple values', async () => {
51 | const [token, name] = await storage.get(['token', 'name'])
52 | expect(token).toBe(store.token)
53 | expect(name).toBe(store.name)
54 | expect(window.chrome.storage.local.get.mock.calls.length).toBe(1)
55 | })
56 |
57 | it('return undefined when value not found', async () => {
58 | const nothing = await storage.get('nothing')
59 | expect(nothing).toBe(undefined)
60 | })
61 |
62 | it('does not have browser storage available', async () => {
63 | try {
64 | window.chrome.storage = null
65 | await storage.get('token');
66 | } catch (e) {
67 | expect(e).toEqual('Browser storage not available');
68 | }
69 | })
70 | })
71 |
72 | describe('remove', () => {
73 | it('removes a value', async () => {
74 | const store = await storage.remove('token')
75 | expect(store.token).toBe(undefined)
76 | expect(window.chrome.storage.local.remove.mock.calls.length).toBe(1)
77 | })
78 |
79 | it('does not have browser storage available', async () => {
80 | try {
81 | window.chrome.storage = null
82 | await storage.remove('token');
83 | } catch (e) {
84 | expect(e).toEqual('Browser storage not available');
85 | }
86 | })
87 | })
88 |
89 | describe('set', () => {
90 | it('set a new value or values', async () => {
91 | const newStore = await storage.set({age: 1, country: 2})
92 | expect(newStore.age).toBe(1)
93 | expect(newStore.country).toBe(2)
94 | expect(window.chrome.storage.local.set.mock.calls.length).toBe(1)
95 | })
96 |
97 | it('does not have browser storage available', async () => {
98 | try {
99 | window.chrome.storage = null
100 | await storage.set({age: 1, country: 2});
101 | } catch (e) {
102 | expect(e).toEqual('Browser storage not available');
103 | }
104 | })
105 | })
106 |
--------------------------------------------------------------------------------
/src/services/analytics.js:
--------------------------------------------------------------------------------
1 | export default {
2 | trackEvent({ event, options }) {
3 | if (options?.extension?.telemetry) {
4 | window?._gaq?.push(['_trackEvent', event, 'clicked'])
5 | }
6 | },
7 |
8 | trackPageView(options) {
9 | if (options?.extension?.telemetry) {
10 | window?._gaq?.push(['_trackPageview'])
11 | }
12 | },
13 | }
14 |
--------------------------------------------------------------------------------
/src/services/badge.js:
--------------------------------------------------------------------------------
1 | const DEFAULT_COLOR = '#45C8F1'
2 | const RECORDING_COLOR = '#FF0000'
3 |
4 | const DEFAULT_LOGO = './images/logo.png'
5 | const RECORDING_LOGO = './images/logo-red.png'
6 | const PAUSE_LOGO = './images/logo-yellow.png'
7 |
8 | export default {
9 | stop(text) {
10 | chrome.browserAction.setIcon({ path: DEFAULT_LOGO })
11 | chrome.browserAction.setBadgeBackgroundColor({ color: DEFAULT_COLOR })
12 | this.setText(text)
13 | },
14 |
15 | reset() {
16 | this.setText('')
17 | },
18 |
19 | setText(text) {
20 | chrome.browserAction.setBadgeText({ text })
21 | },
22 |
23 | pause() {
24 | chrome.browserAction.setIcon({ path: PAUSE_LOGO })
25 | },
26 |
27 | start() {
28 | chrome.browserAction.setIcon({ path: RECORDING_LOGO })
29 | },
30 |
31 | wait() {
32 | chrome.browserAction.setBadgeBackgroundColor({ color: RECORDING_COLOR })
33 | this.setText('wait')
34 | },
35 | }
36 |
--------------------------------------------------------------------------------
/src/services/browser.js:
--------------------------------------------------------------------------------
1 | const CONTENT_SCRIPT_PATH = 'js/content-script.js'
2 | const RUN_URL = 'https://app.checklyhq.com/checks/new/browser'
3 | const DOCS_URL = 'https://www.checklyhq.com/docs/headless-recorder'
4 | const SIGNUP_URL =
5 | 'https://www.checklyhq.com/product/synthetic-monitoring/?utm_source=Chrome+Extension&utm_medium=Headless+Recorder+Chrome+Extension&utm_campaign=Headless+Recorder&utm_id=Open+Source'
6 |
7 | export default {
8 | getActiveTab() {
9 | return new Promise(function(resolve) {
10 | chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => resolve(tab))
11 | })
12 | },
13 |
14 | async sendTabMessage({ action, value, clean } = {}) {
15 | const tab = await this.getActiveTab()
16 | chrome.tabs.sendMessage(tab.id, { action, value, clean })
17 | },
18 |
19 | injectContentScript() {
20 | return new Promise(function(resolve) {
21 | chrome.tabs.executeScript({ file: CONTENT_SCRIPT_PATH, allFrames: false }, res =>
22 | resolve(res)
23 | )
24 | })
25 | },
26 |
27 | copyToClipboard(text) {
28 | return navigator.permissions.query({ name: 'clipboard-write' })
29 | .then(result => {
30 | if (result.state !== 'granted' && result.state !== 'prompt') {
31 | return Promise.reject()
32 | }
33 |
34 | navigator.clipboard.writeText(text)
35 | })
36 | },
37 |
38 | getChecklyCookie() {
39 | return new Promise(function(resolve) {
40 | chrome.cookies.getAll({}, res =>
41 | resolve(res.find(cookie => cookie.name.startsWith('checkly_has_account')))
42 | )
43 | })
44 | },
45 |
46 | getBackgroundBus() {
47 | return chrome.extension.connect({ name: 'recordControls' })
48 | },
49 |
50 | openOptionsPage() {
51 | chrome.runtime.openOptionsPage?.()
52 | },
53 |
54 | openHelpPage() {
55 | chrome.tabs.create({ url: DOCS_URL })
56 | },
57 |
58 | openChecklyRunner({ code, runner, isLoggedIn }) {
59 | if (!isLoggedIn) {
60 | chrome.tabs.create({ url: SIGNUP_URL })
61 | return
62 | }
63 |
64 | const script = encodeURIComponent(btoa(code))
65 | const url = `${RUN_URL}?framework=${runner}&script=${script}`
66 | chrome.tabs.create({ url })
67 | },
68 | }
69 |
--------------------------------------------------------------------------------
/src/services/constants.js:
--------------------------------------------------------------------------------
1 | export const recordingControls = {
2 | EVENT_RECORDER_STARTED: 'EVENT_RECORDER_STARTED',
3 | GET_VIEWPORT_SIZE: 'GET_VIEWPORT_SIZE',
4 | GET_CURRENT_URL: 'GET_CURRENT_URL',
5 | GET_SCREENSHOT: 'GET_SCREENSHOT',
6 | }
7 |
8 | export const popupActions = {
9 | START: 'START',
10 | STOP: 'STOP',
11 | CLEAN_UP: 'CLEAN_UP',
12 | PAUSE: 'PAUSE',
13 | UN_PAUSE: 'UN_PAUSE',
14 | }
15 |
16 | export const isDarkMode = () =>
17 | window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : false
18 |
--------------------------------------------------------------------------------
/src/services/selector.js:
--------------------------------------------------------------------------------
1 | import { finder } from '@medv/finder/finder.js'
2 |
3 | export default function selector(e, { dataAttribute } = {}) {
4 | if (dataAttribute && e.target.getAttribute(dataAttribute)) {
5 | return `[${dataAttribute}="${e.target.getAttribute(dataAttribute)}"]`
6 | }
7 |
8 | if (e.target.id) {
9 | return `#${e.target.id}`
10 | }
11 |
12 | return finder(e.target, {
13 | seedMinLength: 5,
14 | optimizedMinLength: e.target.id ? 2 : 10,
15 | attr: name => name === dataAttribute,
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/src/services/storage.js:
--------------------------------------------------------------------------------
1 | export default {
2 | get(keys) {
3 | if (!chrome.storage || !chrome.storage.local) {
4 | return Promise.reject('Browser storage not available')
5 | }
6 |
7 | return new Promise(resolve => chrome.storage.local.get(keys, props => resolve(props)))
8 | },
9 |
10 | set(props) {
11 | if (!chrome.storage || !chrome.storage.local) {
12 | return Promise.reject('Browser storage not available')
13 | }
14 |
15 | return new Promise(resolve => chrome.storage.local.set(props, res => resolve(res)))
16 | },
17 |
18 | remove(keys) {
19 | if (!chrome.storage || !chrome.storage.local) {
20 | return Promise.reject('Browser storage not available')
21 | }
22 |
23 | return new Promise(resolve => chrome.storage.local.remove(keys, res => resolve(res)))
24 | },
25 | }
26 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore } from 'vuex'
2 |
3 | import { overlayActions } from '@/modules/overlay/constants'
4 |
5 | function clearState(state) {
6 | state.isClosed = false
7 | state.isPaused = false
8 | state.isStopped = false
9 | state.screenshotMode = false
10 | state.screenshotClippedMode = false
11 |
12 | state.recording = []
13 | }
14 |
15 | const store = createStore({
16 | state() {
17 | return {
18 | isCopying: false,
19 | isClosed: false,
20 | isPaused: false,
21 | isStopped: false,
22 | darkMode: false,
23 | screenshotMode: false,
24 | screenshotClippedMode: false,
25 | hasRecorded: false,
26 |
27 | dataAttribute: '',
28 | takeScreenshot: false,
29 |
30 | recording: [],
31 | }
32 | },
33 |
34 | mutations: {
35 | showRecorded(state) {
36 | state.hasRecorded = true
37 | setTimeout(() => (state.hasRecorded = false), 250)
38 | },
39 |
40 | showCopy(state) {
41 | state.isCopying = true
42 | setTimeout(() => (state.isCopying = false), 500)
43 | },
44 |
45 | takeScreenshot(state) {
46 | state.takeScreenshot = true
47 | },
48 |
49 | setDataAttribute(state, dataAttribute) {
50 | state.dataAttribute = dataAttribute
51 | },
52 |
53 | setDarkMode(state, darkMode) {
54 | state.darkMode = darkMode
55 | },
56 |
57 | setRecording(state, recording) {
58 | state.recording = recording
59 | },
60 |
61 | unpause(state) {
62 | state.isPaused = false
63 | chrome.runtime.sendMessage({ control: overlayActions.UNPAUSE })
64 | },
65 |
66 | pause(state) {
67 | state.isPaused = true
68 | chrome.runtime.sendMessage({ control: overlayActions.PAUSE })
69 | },
70 |
71 | close(state) {
72 | state.isClosed = true
73 | chrome.runtime.sendMessage({ control: overlayActions.CLOSE })
74 | },
75 |
76 | restart(state) {
77 | clearState(state)
78 | chrome.runtime.sendMessage({ control: overlayActions.RESTART })
79 | },
80 |
81 | clear(state) {
82 | clearState(state)
83 | },
84 |
85 | stop(state) {
86 | state.isStopped = true
87 | chrome.runtime.sendMessage({ control: overlayActions.STOP })
88 | },
89 |
90 | copy() {
91 | chrome.runtime.sendMessage({ control: overlayActions.COPY })
92 | },
93 |
94 | toggleScreenshotMode(state) {
95 | state.screenshotMode = !state.screenshotMode
96 | },
97 |
98 | startScreenshotMode(state, isClipped = false) {
99 | chrome.runtime.sendMessage({
100 | control: isClipped ? overlayActions.CLIPPED_SCREENSHOT : overlayActions.FULL_SCREENSHOT,
101 | })
102 |
103 | state.screenshotClippedMode = isClipped
104 | state.screenshotMode = true
105 | },
106 |
107 | stopScreenshotMode(state) {
108 | state.screenshotMode = false
109 | },
110 | },
111 | })
112 |
113 | // TODO: load state from local storage
114 | chrome.storage.onChanged.addListener(({ options = null, recording = null }) => {
115 | if (options) {
116 | store.commit('setDarkMode', options.newValue.extension.darkMode)
117 | }
118 |
119 | if (recording) {
120 | store.commit('setRecording', recording.newValue)
121 | }
122 | })
123 |
124 | export default store
125 |
--------------------------------------------------------------------------------
/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | No recorded events yet
5 |
6 |
7 | Record browser events by clicking record button
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
22 |
--------------------------------------------------------------------------------
/src/views/Recording.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Headless recorder currently recording your browser events.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
18 |
24 |
30 |
31 |
{{
32 | isPaused ? 'RESUME' : 'PAUSE'
33 | }}
34 |
35 |
36 |
41 |
46 |
47 |
RESTART
48 |
49 |
50 |
51 |
52 |
53 |
67 |
--------------------------------------------------------------------------------
/src/views/Results.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
18 | {{ tab }}
19 |
20 |
21 |
22 |
23 |
28 |
29 |
30 |
31 | No code yet...
32 |
33 |
34 |
35 |
36 |
87 |
88 |
108 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | css: {
3 | extract: false,
4 | },
5 |
6 | pages: {
7 | popup: {
8 | template: 'public/browser-extension.html',
9 | entry: './src/popup/main.js',
10 | title: 'Popup',
11 | },
12 | options: {
13 | template: 'public/browser-extension.html',
14 | entry: './src/options/main.js',
15 | title: 'Options',
16 | },
17 | },
18 | pluginOptions: {
19 | browserExtension: {
20 | componentOptions: {
21 | background: {
22 | entry: 'src/background/index.js',
23 | },
24 | contentScripts: {
25 | entries: {
26 | 'content-script': ['src/content-scripts/index.js'],
27 | },
28 | },
29 | },
30 | },
31 | },
32 | }
33 |
--------------------------------------------------------------------------------