Full Code of checkly/headless-recorder for AI

main 4fb93c125f40 cached
88 files
165.5 KB
47.2k tokens
138 symbols
1 requests
Download .txt
Repository: checkly/headless-recorder
Branch: main
Commit: 4fb93c125f40
Files: 88
Total size: 165.5 KB

Directory structure:
gitextract_qm3mcne8/

├── .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
├── babel.config.js
├── dependabot.yml
├── jest.config.js
├── jest.setup.js
├── jsconfig.json
├── package.json
├── postcss.config.js
├── public/
│   ├── _locales/
│   │   └── en/
│   │       └── messages.json
│   └── browser-extension.html
├── src/
│   ├── __tests__/
│   │   ├── build.spec.js
│   │   └── helpers.js
│   ├── assets/
│   │   ├── animations.css
│   │   ├── code.css
│   │   └── 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

================================================
FILE CONTENTS
================================================

================================================
FILE: .browserslistrc
================================================
> 1%
last 2 versions
not dead


================================================
FILE: .eslintrc.js
================================================
module.exports = {
  root: true,

  env: {
    node: true,
    webextensions: true,
  },

  extends: [
    'plugin:vuejs-accessibility/recommended',
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/prettier',
  ],

  parserOptions: {
    parser: 'babel-eslint',
  },

  rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'vuejs-accessibility/label-has-for': 'off',
  },

  overrides: [
    {
      files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'],
      env: {
        jest: true,
      },
    },
  ],
}


================================================
FILE: .github/auto_assign.yml
================================================
addReviewers: true
addAssignees: author

reviewers:
  - pilimartinez
  - ianaya89

skipKeywords:
  - wip

numberOfReviewers: 1


================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    # Runs at 9am CET only on weekdays
    schedule:
      time: "13:00"
      interval: "daily"
      timezone: Europe/Berlin
    open-pull-requests-limit: 3
    labels:
      - "dependencies"


================================================
FILE: .github/pull_request_template.md
================================================
# Pull Request Template

## Description

Please include a summary of the change or which issue is fixed. Also, include relevant motivation and context.
Remember, as mentioned in the [contribution guidelines](https://github.com/checkly/puppeteer-recorder/blob/main/CONTRIBUTING.md) that
PR's should be as atomic as possible 1 feature === 1 PR. 1 bugfix === 1 PR.

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

## Checklist:

- [ ] My code follows the style guidelines of this project. `npm run lint` passes with no errors.
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes. `npm run test` passes with no errors.


================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
name: "CodeQL"

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  schedule:
    - cron: '38 6 * * 5'

jobs:
  analyze:
    name: Analyze
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
      security-events: write

    strategy:
      fail-fast: false
      matrix:
        language: [ 'javascript' ]

    steps:
    - name: Checkout repository
      uses: actions/checkout@v2

    # Initializes the CodeQL tools for scanning.
    - name: Initialize CodeQL
      uses: github/codeql-action/init@v1
      with:
        languages: ${{ matrix.language }}
        # If you wish to specify custom queries, you can do so here or in a config file.
        # By default, queries listed here will override any specified in a config file.
        # Prefix the list here with "+" to use these queries and those in the config file.
        # queries: ./path/to/local/query, your-org/your-repo/queries@main

    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
    # If this step fails, then you should remove it and run the build manually (see below)
    - name: Autobuild
      uses: github/codeql-action/autobuild@v1

    # ℹ️ Command-line programs to run using the OS shell.
    # 📚 https://git.io/JvXDl

    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
    #    and modify them (or add more) to build your code if your project
    #    uses a compiled language

    #- run: |
    #   make bootstrap
    #   make release

    - name: Perform CodeQL Analysis
      uses: github/codeql-action/analyze@v1


================================================
FILE: .github/workflows/lint-build-test.yml
================================================
name: Lint & Build & Test

on:
  push:
    branches: [ main ]
  pull_request:
    types: [ opened, synchronize ]

jobs:
  dependencies:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - run: PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm install
    - uses: actions/cache@v1
      id: cache-dependencies
      with:
        path: '.'
        key: ${{ github.sha }}

  ci:
    runs-on: ubuntu-latest
    needs: dependencies
    steps:
    - uses: actions/cache@v1
      id: restore-dependencies
      with:
        path: '.'
        key: ${{ github.sha }}
    - name: Lint
      run: npm run lint
    - name: Build
      run: npm run build


================================================
FILE: .gitignore
================================================
.DS_Store
node_modules
/dist


# local env files
.env.local
.env.*.local

# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

# Vue Browser Extension Output
*.pem
*.pub
*.zip
/artifacts


================================================
FILE: .npmrc
================================================
save-exact=true


================================================
FILE: .prettierrc.js
================================================
module.exports = {
  trailingComma: 'es5',
  tabWidth: 2,
  semi: false,
  singleQuote: true,
  printWidth: 100,
}


================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] - 2021-07-08
### Added
- New visual identity by [@nucro](https://twitter.com/nucro).
- In page overlay to handle recording and take screenshots
- Visual feedback when taking screenshots
- New code structure organized in modules and services
- Dark mode support
- Migrate to Vue 3 and dependencies update
- Migrate CSS to Tailwind (except for Overlay components)
- Selector preview while recording
- Restart button while recording
- Allow run scripts directly on Checkly 🦝
- First draft of Vuex shared store

### Changed
- Make Playwright default tab
- Use non-async wrap as default
- Full page screenshots use `fullPage` property
- Replace clipped screenshots with element screenshots
- Improve selector generation giving relevance to `ID` and `data-attributes` [#64](https://github.com/checkly/headless-recorder/issues/64)
- General bug fixing
- Improve code reusability and events management

### Removed
- Screenshots context menu
- Events recording list

<br>

## [0.8.2] - 2020-12-15

### Changed
- Specify custom key for input record [#111](https://github.com/checkly/headless-recorder/pulls/111)
- Fix input escaping [#119](https://github.com/checkly/headless-recorder/pulls/119)

================================================
FILE: CONTRIBUTING.md
================================================
# Contributing

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.

## New feature guidelines

When authoring new features or extending existing ones, consider the following:
- All new features should be accompanied first with a Github issues describing the feature and its necessity.
- We aim for simplicity. Too many options, buttons, panels etc. detract from that.
- Features should serve the general public. Very specific things for your use case are frowned upon.

## Getting set up

1. Clone this repository

```bash
git clone https://github.com/checkly/headless-recorder
cd headless-recorder
```

2. Install dependencies

```bash
npm install
```

## Code reviews

All submissions, including submissions by project members, require review. We
use GitHub pull requests for this purpose. Consult
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
information on using pull requests.

> 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.

## Code Style

- Coding style is fully defined in [.eslintrc](https://github.com/checkly/headless-recorder/blob/main/.eslintrc.js)
- Comments should be generally avoided. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory.

To run code linter, use:

```bash
npm run lint
```
## Commit Messages

Commit messages should follow the Semantic Commit Messages format:

```
label(namespace): title

description

footer
```

1. *label* is one of the following:
    - `fix` - puppeteer bug fixes.
    - `feat` - puppeteer features.
    - `docs` - changes to docs, e.g. `docs(api.md): ..` to change documentation.
    - `test` - changes to puppeteer tests infrastructure.
    - `style` - puppeteer code style: spaces/alignment/wrapping etc.
    - `chore` - build-related work, e.g. doclint changes / travis / appveyor.
2. *namespace* is put in parenthesis after label and is optional.
3. *title* is a brief summary of changes.
4. *description* is **optional**, new-line separated from title and is in present tense.

Example:

```
fix(code-generator): fix page.pizza method

This patch fixes page.pizza so that it works with iframes.

Fixes #123, Fixes #234
```

## Adding New Dependencies

For all dependencies (both installation and development):
- **Do not add** a dependency if the desired functionality is easily implementable.
- If adding a dependency, it should be well-maintained and trustworthy.

A barrier for introducing new installation dependencies is especially high:
- **Do not add** installation dependency unless it's critical to project success.

## Writing Tests

- Every feature should be accompanied by a test.
- Every public api event/method should be accompanied by a test.
- Tests should be *hermetic*. Tests should not depend on external services.

We use Jest for testing. Tests are located in the various `__test__` folders.

- To run all tests:

```bash
npm run test
```


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 Checkly Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: PRIVACY_POLICY.md
================================================
# Privacy policy
> Last Updated: July 12, 2021

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”).

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.

### TL;DR:
  - We will not sell your data to anyone.
  - We use Google Analytics to see how you interact with extension. You can opt out of both Google Analytics.

## 1. Information Collection and Use

Our primary goals in collecting information from you are to provide you with the products and services made available through the Service.
We may also use your information to operate, maintain, and enhance the Service and its features.

## 2. Log Data

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.

## 3. Cookies and Automatically Collected Information

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.
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.

### Google Analytics
We use Google Analytics to measure the effectiveness of our Extension.

## 4. Security
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.



================================================
FILE: README.md
================================================
# 🚨 Deprecated!
As of Dec 16th 2022, Headless Recorder is fully deprecated. No new changes, support, maintenance or new features are expected to land.

For more information and possible alternatives refer to this [issue](https://github.com/checkly/headless-recorder/issues/232).

</p>

<p align="center">
  <img width="200px" src="./assets/logo.png" alt="Headless Recorder" />
</p>

<p>
  <img height="128" src="https://www.checklyhq.com/images/footer-logo.svg" align="right" />
  <h1>Headless Recorder</h1>
</p>

<p>
  <img src="https://github.com/checkly/headless-recorder/workflows/Lint%20&%20Build%20&%20Test/badge.svg?branch=main" alt="Github Build"/>
  <img src="https://img.shields.io/chrome-web-store/users/djeegiggegleadkkbgopoonhjimgehda?label=Chrome%20Webstore%20-%20Users" alt="Chrome Webstore Users" />
  <img src="https://img.shields.io/chrome-web-store/v/djeegiggegleadkkbgopoonhjimgehda?label=Chrome%20Webstore" alt="Chrome Webstore Version" />
</p>


> 🎥 Headless recorder is a Chrome extension that records your browser interactions and generates a Playwright/Puppeteer script.


<br>
<p align="center">
  <img src="./assets/hr.gif" alt="Headless recorder demo" />
</p>
<br>

## Overview

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!

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/).

> 🤔 Do you want to learn more about Puppeteer and Playwright? Check our open [Headless Guides](https://www.checklyhq.com/learn/headless/)

<br>

## What you can do?

- Records clicks and type events.
- Add waitForNavigation, setViewPort and other useful clauses.
- Generates a Playwright & Puppeteer script.
- Preview CSS selectors of HTML elements.
- Take full page and element screenshots.
- Pause, resume and restart recording.
- Persist latest script in your browser
- Copy to clipboard.
- Run generated scripts directly on [Checkly](https://checklyhq.com)
- Flexible configuration options and dark mode support.
- Allows `data-id` configuration for element selection.

#### Recorded Events
  - `click`
  - `dblclick`
  - `change`
  - `keydown`
  - `select`
  - `submit`
  - `load`
  - `unload`

> This collection will be expanded in future releases. 💪

<br>

## How to use?

1. Click the icon and hit the red button.
2. 👉 Hit <kbd>tab</kbd> after you finish typing in an `input` element. 👈
3. Click on links, inputs and other elements.
4. Wait for full page load on each navigation.

    **The icon will switch from <img width="24px" height="24px" src="./assets/rec.png" alt="recording icon"/>
    to <img width="24px" height="24px" src="./assets/wait.png" alt="waiting icon"/> to indicate it is ready for more input from you.**

5. Click Pause when you want to navigate without recording anything. Hit Resume to continue recording.

### ⌨️ Shortcuts

- `alt + k`: Toggle overlay
- `alt + shift + F`: Take full page screenshot
- `alt + shift + E`: Take element screenshot

<br>

## Run Locally

After cloning the project, open the terminal and navigate to project root directory.

```bash
$ npm i # install dependencies

$ npm run serve # run development mode

$ npm run test # run test cases

$ npm run lint # run and fix linter issues

$ npm run build # build and zip for production
```

<br>

## Install Locally

1. Open chrome and navigate to extensions page using this URL: [`chrome://extensions`](chrome://extensions).
1. Make sure "**Developer mode**" is enabled.
1. Click "**Load unpacked extension**" button, browse the `headless-recorder/dist` directory and select it.

![](./assets/dev-guide.png)

<br>

## Release

1. Bump version using `npm version` (patch, minor, major).
2. Push changes with tags `git push --tags`
3. Generate a release using **gren**: `gren release --override --data-source=milestones --milestone-match="{{tag_name}}"`

> 🚨 Make sure all issues associated with the new version are linked to a milestone with the name of the tag.

<br>

## Credits

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).

<br>

## License

[MIT](https://github.com/checkly/headless-recorder/blob/main/LICENSE)


<p align="center">
  <a href="https://checklyhq.com?utm_source=github&utm_medium=sponsor-logo-github&utm_campaign=headless-recorder" target="_blank">
  <img width="100px" src="./assets/checkly-logo.png?raw=true" alt="Checkly" />
  </a>
  <br />
  <i><sub>Delightful Active Monitoring for Developers</sub></i>
  <br>
  <b><sub>From Checkly with ♥️</sub></b>
<p>



================================================
FILE: babel.config.js
================================================
module.exports = {
  presets: ['@vue/cli-plugin-babel/preset'],
}


================================================
FILE: dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      time: "09:00"
      interval: "daily"
      timezone: Europe/Berlin
    open-pull-requests-limit: 3
    labels:
      - "dependencies"
    reviewers:
      - "ianaya89"

================================================
FILE: jest.config.js
================================================
module.exports = {
  preset: '@vue/cli-plugin-unit-jest',
  transform: {
    '^.+\\.vue$': 'vue-jest',
    "^.+\\.js$": "babel-jest",
  },
  setupFilesAfterEnv: ['./jest.setup.js'],
  moduleFileExtensions: ["js", "json", "vue"],
}


================================================
FILE: jest.setup.js
================================================
import '@testing-library/jest-dom'


================================================
FILE: jsconfig.json
================================================
{
  "include": [
    "./src/**/*"
  ]
}

================================================
FILE: package.json
================================================
{
  "name": "headless-recorder",
  "version": "1.1.0",
  "scripts": {
    "serve": "vue-cli-service build --mode development --watch",
    "build": "vue-cli-service build",
    "test": "npm run test:unit",
    "test:unit": "vue-cli-service test:unit __tests__/.*.spec.js",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "@headlessui/vue": "1.2.0",
    "@medv/finder": "2.0.0",
    "@tailwindcss/postcss7-compat": "2.0.2",
    "@vueuse/core": "4.0.8",
    "autoprefixer": "9",
    "core-js": "3.6.5",
    "css-selector-generator": "3.0.1",
    "lodash": "4.17.21",
    "pinia": "2.0.0-beta.3",
    "postcss": "7",
    "tailwindcss": "npm:@tailwindcss/postcss7-compat@2.0.2",
    "vue": "3.0.6",
    "vue-tippy": "6.0.0-alpha.30",
    "vue3-highlightjs": "1.0.5",
    "vuex": "4.0.2"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "5.12.0",
    "@vue/cli-plugin-babel": "4.5.0",
    "@vue/cli-plugin-eslint": "4.5.0",
    "@vue/cli-plugin-unit-jest": "4.5.12",
    "@vue/cli-service": "4.5.0",
    "@vue/compiler-sfc": "3.0.0",
    "@vue/eslint-config-prettier": "6.0.0",
    "@vue/test-utils": "2.0.0-rc.6",
    "@vue/vue3-jest": "27.0.0-alpha.1",
    "babel-core": "7.0.0-bridge.0",
    "babel-eslint": "10.1.0",
    "eslint": "6.7.2",
    "eslint-plugin-prettier": "3.1.3",
    "eslint-plugin-vue": "7.0.0",
    "eslint-plugin-vuejs-accessibility": "0.6.2",
    "jest": "27.5.1",
    "jest-vue-preprocessor": "1.7.1",
    "node-sass": "5.0.0",
    "playwright": "1.10.0",
    "prettier": "1.19.1",
    "puppeteer": "9.0.0",
    "sass-loader": "10.1.1",
    "typescript": "3.9.3",
    "vue-cli-plugin-browser-extension": "0.25.1",
    "vue-cli-plugin-tailwind": "2.0.6",
    "vue-jest": "3.0.7"
  }
}


================================================
FILE: postcss.config.js
================================================
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}


================================================
FILE: public/_locales/en/messages.json
================================================
{
  "extName": {
    "message": "headless-recorder-v2",
    "description": ""
  }
}


================================================
FILE: public/browser-extension.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <title><%= htmlWebpackPlugin.options.title %></title>
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap" rel="stylesheet">
</head>
<body>
  <div id="app"></div>
</body>
</html>


================================================
FILE: src/__tests__/build.spec.js
================================================
import puppeteer from 'puppeteer'
import { launchPuppeteerWithExtension } from './helpers'

describe('install', () => {
  test('it installs the extension', async () => {
    const browser = await launchPuppeteerWithExtension(puppeteer)
    expect(browser).toBeTruthy()
    browser.close()
  }, 5000)
})


================================================
FILE: src/__tests__/helpers.js
================================================
import path from 'path'
import { scripts } from '../../package.json'
const util = require('util')
const exec = util.promisify(require('child_process').exec)

const extensionPath = path.join(__dirname, '../../dist')

export const launchPuppeteerWithExtension = function(puppeteer) {
  const options = {
    headless: false,
    ignoreHTTPSErrors: true,
    devtools: true,
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`,
      '--no-sandbox',
      '--disable-setuid-sandbox',
    ],
  }

  if (process.env.CI) {
    options.executablePath = process.env.PUPPETEER_EXEC_PATH // Set by docker on github actions
  }

  return puppeteer.launch(options)
}

export const runBuild = function() {
  return exec(scripts.build)
}


================================================
FILE: src/assets/animations.css
================================================
@keyframes flash {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes slideup {
  0% {
    transform: translateY(100%);
  }

  100% {
    transform: translateY(0%);
  }
}

@keyframes pop {
  0% {
    transform: scale(1);
  }

  0% {
    transform: scale(1.25);
  }

  100% {
    transform: scale(1);
  }
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
}

.headless-recorder-flash {
  animation-name: flash;
  animation-duration: 0.5s;
  animation-iteration-count: 1;
  animation-timing-function: ease-in-out;
}

.headless-recorder-camera-cursor {
  cursor: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACMSURBVHgBzZDrDUBAEITnVEIHVIoKUAkd0MHphCXrstm4R/jBJF9yu5d9DfAXWWJT2DfFqVjDj0NGNd6QoEwVSC61RMEDKmLAzSQfHZETI8czx40cFGpQcpHMjdzkjA3Ct/r+XT5DWDkxqdzCmzmFTqi5yazW75HowWVkKTaq5X/Mg6gOD1Y814rPtQPiEFi9rPKoQQAAAABJRU5ErkJggg=='),
    auto !important;
}

================================================
FILE: src/assets/code.css
================================================
.hljs-line {
  color: '#ADAACC';
  margin-right: 8px;
}

/* Comment */
.hljs-comment,
.hljs-quote {
  color: #d4d0ab;
}

/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
  color: #C792EA;
}

.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
  color: #7DD8C7;
}

.hljs-number {
  color: #FF628C;
}

/* Yellow */
.hljs-attribute {
  color: #ffd700;
}

/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
  color: #ECC48D;
}

/* Blue */
.hljs-title,
.hljs-section {
  color: #00e0e0;
}

/* Purple */
.hljs-keyword,
.hljs-selector-tag {
  color: #C792EA;
}

.hljs {
  display: block;
  overflow-x: auto;
  background: #2b2b2b;
  color: #f8f8f2;
  padding: 0.5em;
}

.hljs-emphasis {
  font-style: italic;
}

.hljs-strong {
  font-weight: bold;
}

@media screen and (-ms-high-contrast: active) {
  .hljs-addition,
  .hljs-attribute,
  .hljs-built_in,
  .hljs-builtin-name,
  .hljs-bullet,
  .hljs-comment,
  .hljs-link,
  .hljs-literal,
  .hljs-meta,
  .hljs-number,
  .hljs-params,
  .hljs-string,
  .hljs-symbol,
  .hljs-type,
  .hljs-quote {
        color: highlight;
    }

    .hljs-keyword,
    .hljs-selector-tag {
        font-weight: bold;
    }
}


================================================
FILE: src/assets/tailwind.css
================================================
@tailwind base;

@tailwind components;

@tailwind utilities;


================================================
FILE: src/background/index.js
================================================
import badge from '@/services/badge'
import browser from '@/services/browser'
import storage from '@/services/storage'
import { popupActions, recordingControls } from '@/services/constants'
import { overlayActions } from '@/modules/overlay/constants'
import { headlessActions } from '@/modules/code-generator/constants'

import CodeGenerator from '@/modules/code-generator'

class Background {
  constructor() {
    this._recording = []
    this._boundedMessageHandler = null
    this._boundedNavigationHandler = null
    this._boundedWaitHandler = null

    this.overlayHandler = null

    this._badgeState = ''
    this._isPaused = false

    this._menuId = 'PUPPETEER_RECORDER_CONTEXT_MENU'
    this._boundedMenuHandler = null

    // Some events are sent double on page navigations to simplify the event recorder.
    // We keep some simple state to disregard events if needed.
    this._hasGoto = false
    this._hasViewPort = false
  }

  init() {
    chrome.extension.onConnect.addListener(port => {
      port.onMessage.addListener(msg => this.handlePopupMessage(msg))
    })
  }

  async start() {
    await this.cleanUp()

    this._badgeState = ''
    this._hasGoto = false
    this._hasViewPort = false

    await browser.injectContentScript()
    this.toggleOverlay({ open: true, clear: true })

    this._boundedMessageHandler = this.handleMessage.bind(this)
    this._boundedNavigationHandler = this.handleNavigation.bind(this)
    this._boundedWaitHandler = () => badge.wait()

    this.overlayHandler = this.handleOverlayMessage.bind(this)

    // chrome.contextMenus.create({
    //   id: this._menuId,
    //   title: 'Headless Recorder',
    //   contexts: ['all'],
    // })

    // chrome.contextMenus.create({
    //   id: this._menuId + 'SELECTOR',
    //   title: 'Copy Selector',
    //   parentId: this._menuId,
    //   contexts: ['all'],
    // })

    // this._boundedMenuHandler = this.handleMenuInteraction.bind(this)
    // chrome.contextMenus.onClicked.addListener(this._boundedMenuHandler)

    chrome.runtime.onMessage.addListener(this._boundedMessageHandler)
    chrome.runtime.onMessage.addListener(this.overlayHandler)

    chrome.webNavigation.onCompleted.addListener(this._boundedNavigationHandler)
    chrome.webNavigation.onBeforeNavigate.addListener(this._boundedWaitHandler)

    badge.start()
  }

  stop() {
    this._badgeState = this._recording.length > 0 ? '1' : ''

    chrome.runtime.onMessage.removeListener(this._boundedMessageHandler)
    chrome.webNavigation.onCompleted.removeListener(this._boundedNavigationHandler)
    chrome.webNavigation.onBeforeNavigate.removeListener(this._boundedWaitHandler)
    // chrome.contextMenus.onClicked.removeListener(this._boundedMenuHandler)

    badge.stop(this._badgeState)

    storage.set({ recording: this._recording })
  }

  pause() {
    badge.pause()
    this._isPaused = true
  }

  unPause() {
    badge.start()
    this._isPaused = false
  }

  cleanUp() {
    this._recording = []
    this._isPaused = false
    badge.reset()

    return new Promise(function(resolve) {
      chrome.storage.local.remove('recording', () => resolve())
    })
  }

  recordCurrentUrl(href) {
    if (!this._hasGoto) {
      this.handleMessage({
        selector: undefined,
        value: undefined,
        action: headlessActions.GOTO,
        href,
      })
      this._hasGoto = true
    }
  }

  recordCurrentViewportSize(value) {
    if (!this._hasViewPort) {
      this.handleMessage({
        selector: undefined,
        value,
        action: headlessActions.VIEWPORT,
      })
      this._hasViewPort = true
    }
  }

  recordNavigation() {
    this.handleMessage({
      selector: undefined,
      value: undefined,
      action: headlessActions.NAVIGATION,
    })
  }

  recordScreenshot(value) {
    this.handleMessage({
      selector: undefined,
      value,
      action: headlessActions.SCREENSHOT,
    })
  }

  // handleMenuInteraction(info, tab) {
  // }

  handleMessage(msg, sender) {
    if (msg.control) {
      return this.handleRecordingMessage(msg, sender)
    }

    if (msg.type === 'SIGN_CONNECT') {
      return
    }

    // NOTE: To account for clicks etc. we need to record the frameId
    // and url to later target the frame in playback
    msg.frameId = sender ? sender.frameId : null
    msg.frameUrl = sender ? sender.url : null

    if (!this._isPaused) {
      this._recording.push(msg)
      storage.set({ recording: this._recording })
    }
  }

  async handleOverlayMessage({ control }) {
    if (!control) {
      return
    }

    if (control === overlayActions.RESTART) {
      chrome.storage.local.set({ restart: true })
      chrome.storage.local.set({ clear: false })
      chrome.runtime.onMessage.removeListener(this.overlayHandler)
      this.stop()
      this.cleanUp()
      this.start()
    }

    if (control === overlayActions.CLOSE) {
      this.toggleOverlay()
      chrome.runtime.onMessage.removeListener(this.overlayHandler)
    }

    if (control === overlayActions.COPY) {
      const { options = {} } = await storage.get('options')
      const generator = new CodeGenerator(options)
      const code = generator.generate(this._recording)

      browser.sendTabMessage({
        action: 'CODE',
        value: options?.code?.showPlaywrightFirst ? code.playwright : code.puppeteer,
      })
    }

    if (control === overlayActions.STOP) {
      chrome.storage.local.set({ clear: true })
      chrome.storage.local.set({ pause: false })
      chrome.storage.local.set({ restart: false })
      this.stop()
    }

    if (control === overlayActions.UNPAUSE) {
      chrome.storage.local.set({ pause: false })
      this.unPause()
    }

    if (control === overlayActions.PAUSE) {
      chrome.storage.local.set({ pause: true })
      this.pause()
    }

    // TODO: the next 3 events do not need to be listened in background
    // content script controller, should be able to handle that directly from overlay
    if (control === overlayActions.CLIPPED_SCREENSHOT) {
      browser.sendTabMessage({ action: overlayActions.TOGGLE_SCREENSHOT_CLIPPED_MODE })
    }

    if (control === overlayActions.FULL_SCREENSHOT) {
      browser.sendTabMessage({ action: overlayActions.TOGGLE_SCREENSHOT_MODE })
    }

    if (control === overlayActions.ABORT_SCREENSHOT) {
      browser.sendTabMessage({ action: overlayActions.CLOSE_SCREENSHOT_MODE })
    }
  }

  handleRecordingMessage({ control, href, value, coordinates }) {
    if (control === recordingControls.EVENT_RECORDER_STARTED) {
      badge.setText(this._badgeState)
    }

    if (control === recordingControls.GET_VIEWPORT_SIZE) {
      this.recordCurrentViewportSize(coordinates)
    }

    if (control === recordingControls.GET_CURRENT_URL) {
      this.recordCurrentUrl(href)
    }

    if (control === recordingControls.GET_SCREENSHOT) {
      this.recordScreenshot(value)
    }
  }

  handlePopupMessage(msg) {
    if (!msg.action) {
      return
    }

    if (msg.action === popupActions.START) {
      this.start()
    }

    if (msg.action === popupActions.STOP) {
      browser.sendTabMessage({ action: popupActions.STOP })
      this.stop()
    }

    if (msg.action === popupActions.CLEAN_UP) {
      chrome.runtime.onMessage.removeListener(this.overlayHandler)
      msg.value && this.stop()
      this.toggleOverlay()
      this.cleanUp()
    }

    if (msg.action === popupActions.PAUSE) {
      if (!msg.stop) {
        browser.sendTabMessage({ action: popupActions.PAUSE })
      }
      this.pause()
    }

    if (msg.action === popupActions.UN_PAUSE) {
      if (!msg.stop) {
        browser.sendTabMessage({ action: popupActions.UN_PAUSE })
      }
      this.unPause()
    }
  }

  async handleNavigation({ frameId }) {
    await browser.injectContentScript()
    this.toggleOverlay({ open: true, pause: this._isPaused })

    if (frameId === 0) {
      this.recordNavigation()
    }
  }

  // TODO: Use a better naming convention for this arguments
  toggleOverlay({ open = false, clear = false, pause = false } = {}) {
    browser.sendTabMessage({ action: overlayActions.TOGGLE_OVERLAY, value: { open, clear, pause } })
  }
}

window.headlessRecorder = new Background()
window.headlessRecorder.init()


================================================
FILE: src/components/Button.vue
================================================
<template>
  <button
    class="font-semibold text-xs text-gray-darkest inline-flex justify-center items-center rounded-sm p-2"
    :class="{
      'text-white': dark,
      'bg-gray-dark hover:bg-gray-darkest': dark,
      'text-gray-darkest': !dark,
      'bg-blue hover:bg-blue-dark': !dark,
    }"
  >
    <slot />
  </button>
</template>

<script>
export default {
  name: 'Button',

  props: {
    dark: { type: Boolean, default: false },
  },
}
</script>


================================================
FILE: src/components/Footer.vue
================================================
<template>
  <div class="flex px-4 py-3 justify-between items-center mt-3">
    <a href="https://checklyhq.com" target="_blank">
      <img src="/images/checkly-logo.svg" alt="Checkly logo" class="w-24" />
    </a>
    <span class="text-gray-darkish">Version {{ version }}</span>
  </div>
</template>

<script>
import { ref } from 'vue'
import { version } from '../../package.json'

export default {
  name: 'ChecklyBadge',

  setup() {
    return {
      version: ref(version),
    }
  },
}
</script>


================================================
FILE: src/components/Header.vue
================================================
<template>
  <div class="flex justify-between items-center p-4 pb-0 mb-2">
    <h1 role="button" class="text-sm font-semibold text-gray-darkest dark:text-gray-lightest">
      Headless Recorder
    </h1>
    <div class="flex">
      <button @click="$emit('dark')" class="ml-4">
        <img src="@/assets/icons/moon.svg" alt="help" class="w-4" />
      </button>
      <button @click="$emit('help')" class="ml-2">
        <img src="@/assets/icons/question.svg" alt="help" class="w-4" />
      </button>
      <button @click="$emit('options')" class="ml-2">
        <img src="@/assets/icons/gear.svg" alt="settings" class="w-4" />
      </button>
    </div>
  </div>
</template>


================================================
FILE: src/components/RecordingLabel.vue
================================================
<template>
  <div
    data-test-id="recording-badge"
    class="flex text-2xl justify-center items-center text-red font-semibold"
    :class="{ 'text-yellow': text === 'Paused', 'animate-pulse': text !== 'Paused' }"
  >
    {{ text }}
  </div>
</template>

<script>
export default {
  name: 'RecordingLabel',
  props: {
    isPaused: { type: Boolean, default: false },
  },

  computed: {
    text() {
      return this.isPaused ? 'Paused' : 'Recording...'
    },
  },
}
</script>


================================================
FILE: src/components/RoundButton.vue
================================================
<template>
  <button
    class="p-2 bg-white rounded-full border-gray-light border-solid border-4 hover:bg-gray-lightest dark:hover:bg-gray-hover dark:bg-black-shady dark:border-gray-dark"
    :class="{ 'btn-small': small, 'btn-medium': medium }"
  >
    <slot />
  </button>
</template>

<script>
export default {
  props: {
    small: { type: Boolean, default: false },
    medium: { type: Boolean, default: false },
    big: { type: Boolean, default: false },
  },
}
</script>

<style scoped>
.btn-small {
  border-radius: 50%;
  height: 36px;
  width: 36px;
}

.btn-medium {
  height: 72px;
  width: 72px;
}
</style>


================================================
FILE: src/components/Toggle.vue
================================================
<template>
  <div class="flex items-center mb-3">
    <button
      type="button"
      :class="[
        modelValue ? 'bg-blue' : 'bg-gray',
        'relative inline-flex flex-shrink-0 h-4 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue',
      ]"
      role="switch"
      aria-checked="false"
      @click="toggle"
    >
      <span
        aria-hidden="true"
        :class="[
          modelValue ? 'translate-x-4' : 'translate-x-0',
          'm-px pointer-events-none inline-block h-2.5 w-2.5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
        ]"
      ></span>
    </button>
    <span class="ml-4">
      <span class="text-sm text-gray-dark dark:text-gray-light">
        <slot />
      </span>
    </span>
  </div>
</template>

<script>
export default {
  name: 'Toggle',
  props: { modelValue: { type: Boolean, default: true } },

  setup(props, context) {
    function toggle() {
      context.emit('update:modelValue', !props.modelValue)
    }

    return { toggle }
  },
}
</script>


================================================
FILE: src/components/__tests__/RecordingTab.spec.js
================================================
import { mount } from '@vue/test-utils'
import RecordingTab from '../RecordingTab'

describe('RecordingTab.vue', () => {
  test('it has the correct pristine / empty state', () => {
    const wrapper = mount(RecordingTab)
    expect(wrapper.element).toMatchSnapshot()
  })

  test('it has the correct waiting for events state', () => {
    const wrapper = mount(RecordingTab, { props: { isRecording: true } })
    expect(wrapper.element).toMatchSnapshot()
    expect(wrapper.find('.event-list').element).toBeEmpty()
  })

  test('it has the correct recording Puppeteer custom events state', () => {
    const wrapper = mount(RecordingTab, {
      props: {
        isRecording: true,
        liveEvents: [
          {
            action: 'goto*',
            href: 'http://example.com',
          },
          {
            action: 'viewport*',
            selector: undefined,
            value: { width: 1280, height: 800 },
          },
          {
            action: 'navigation*',
            selector: undefined,
          },
        ],
      },
    })
    expect(wrapper.element).toMatchSnapshot()
    expect(wrapper.find('.event-list').element).not.toBeEmpty()
  })

  test('it has the correct recording DOM events state', () => {
    const wrapper = mount(RecordingTab, {
      props: {
        isRecording: true,
        liveEvents: [
          {
            action: 'click',
            selector: '.main > a.link',
            href: 'http://example.com',
          },
        ],
      },
    })
    expect(wrapper.element).toMatchSnapshot()
    expect(wrapper.find('.event-list').element).not.toBeEmpty()
  })
})


================================================
FILE: src/components/__tests__/ResultsTab.spec.js
================================================
import { mount } from '@vue/test-utils'
import VueHighlightJS from 'vue3-highlightjs'

import ResultsTab from '../ResultsTab'

describe('RecordingTab.vue', () => {
  test('it has the correct pristine / empty state', () => {
    const wrapper = mount(ResultsTab)
    expect(wrapper.element).toMatchSnapshot()
    expect(wrapper.find('code.javascript').exists()).toBe(false)
  })

  test('it show a code box when there is code', () => {
    const wrapper = mount(ResultsTab, {
      global: {
        plugins: [VueHighlightJS],
      },
      props: { puppeteer: `await page.click('.class')` },
    })
    expect(wrapper.element).toMatchSnapshot()
    expect(wrapper.find('code.javascript').exists()).toBe(true)
  })

  test('it render tabs for puppeteer & playwright', () => {
    const wrapper = mount(ResultsTab)
    expect(wrapper.findAll('.tabs__action').length).toEqual(2)
  })

  test('it render playwright first when option is present', async () => {
    const wrapper = await mount(ResultsTab, {
      props: {
        options: {
          code: {
            showPlaywrightFirst: true,
          },
        },
      },
    })
    expect(wrapper.find('.tabs__action').text()).toEqual('🎭playwright')
  })
})


================================================
FILE: src/components/__tests__/__snapshots__/RecordingTab.spec.js.snap
================================================
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`RecordingTab.vue it has the correct pristine / empty state 1`] = `
<div
  class="tab recording-tab"
>
  <div
    class="content"
  >
    <div
      class="empty"
    >
      <img
        alt="desert"
        src=""
        width="0"
      />
      <h3>
        No recorded events yet
      </h3>
      <p
        class="text-muted"
      >
        Click record to begin
      </p>
      <div
        class="nag-cta"
      >
        <a
          href="https://checklyhq.com/headless-recorder"
          target="_blank"
        >
          Puppeteer Recorder is now 
          <strong>
            Headless Recorder
          </strong>
           and supports Playwright →
        </a>
      </div>
    </div>
    <div
      class="events"
      style="display: none;"
    >
      <p
        class="text-muted text-center loading"
      >
         Waiting for events 
      </p>
      <ul
        class="event-list"
      >
        
        
      </ul>
    </div>
  </div>
</div>
`;

exports[`RecordingTab.vue it has the correct recording DOM events state 1`] = `
<div
  class="tab recording-tab"
>
  <div
    class="content"
  >
    <div
      class="empty"
      style="display: none;"
    >
      <img
        alt="desert"
        src=""
        width="0"
      />
      <h3>
        No recorded events yet
      </h3>
      <p
        class="text-muted"
      >
        Click record to begin
      </p>
      <div
        class="nag-cta"
        style="display: none;"
      >
        <a
          href="https://checklyhq.com/headless-recorder"
          target="_blank"
        >
          Puppeteer Recorder is now 
          <strong>
            Headless Recorder
          </strong>
           and supports Playwright →
        </a>
      </div>
    </div>
    <div
      class="events"
    >
      <p
        class="text-muted text-center loading"
        style="display: none;"
      >
         Waiting for events 
      </p>
      <ul
        class="event-list"
      >
        
        <li
          class="event-list-item"
        >
          <div
            class="event-label"
          >
            1.
          </div>
          <div
            class="event-description"
          >
            <div
              class="event-action"
            >
              click
            </div>
            <div
              class="event-props text-muted"
            >
              .main &gt; a.link
            </div>
          </div>
        </li>
        
      </ul>
    </div>
  </div>
</div>
`;

exports[`RecordingTab.vue it has the correct recording Puppeteer custom events state 1`] = `
<div
  class="tab recording-tab"
>
  <div
    class="content"
  >
    <div
      class="empty"
      style="display: none;"
    >
      <img
        alt="desert"
        src=""
        width="0"
      />
      <h3>
        No recorded events yet
      </h3>
      <p
        class="text-muted"
      >
        Click record to begin
      </p>
      <div
        class="nag-cta"
        style="display: none;"
      >
        <a
          href="https://checklyhq.com/headless-recorder"
          target="_blank"
        >
          Puppeteer Recorder is now 
          <strong>
            Headless Recorder
          </strong>
           and supports Playwright →
        </a>
      </div>
    </div>
    <div
      class="events"
    >
      <p
        class="text-muted text-center loading"
        style="display: none;"
      >
         Waiting for events 
      </p>
      <ul
        class="event-list"
      >
        
        <li
          class="event-list-item"
        >
          <div
            class="event-label"
          >
            1.
          </div>
          <div
            class="event-description"
          >
            <div
              class="event-action"
            >
              goto*
            </div>
            <div
              class="event-props text-muted"
            >
              http://example.com
            </div>
          </div>
        </li>
        <li
          class="event-list-item"
        >
          <div
            class="event-label"
          >
            2.
          </div>
          <div
            class="event-description"
          >
            <div
              class="event-action"
            >
              viewport*
            </div>
            <div
              class="event-props text-muted"
            >
              width: 1280, height: 800
            </div>
          </div>
        </li>
        <li
          class="event-list-item"
        >
          <div
            class="event-label"
          >
            3.
          </div>
          <div
            class="event-description"
          >
            <div
              class="event-action"
            >
              navigation*
            </div>
            <div
              class="event-props text-muted"
            />
          </div>
        </li>
        
      </ul>
    </div>
  </div>
</div>
`;

exports[`RecordingTab.vue it has the correct waiting for events state 1`] = `
<div
  class="tab recording-tab"
>
  <div
    class="content"
  >
    <div
      class="empty"
      style="display: none;"
    >
      <img
        alt="desert"
        src=""
        width="0"
      />
      <h3>
        No recorded events yet
      </h3>
      <p
        class="text-muted"
      >
        Click record to begin
      </p>
      <div
        class="nag-cta"
        style="display: none;"
      >
        <a
          href="https://checklyhq.com/headless-recorder"
          target="_blank"
        >
          Puppeteer Recorder is now 
          <strong>
            Headless Recorder
          </strong>
           and supports Playwright →
        </a>
      </div>
    </div>
    <div
      class="events"
    >
      <p
        class="text-muted text-center loading"
      >
         Waiting for events 
      </p>
      <ul
        class="event-list"
      >
        
        
      </ul>
    </div>
  </div>
</div>
`;


================================================
FILE: src/components/__tests__/__snapshots__/ResultsTab.spec.js.snap
================================================
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`RecordingTab.vue it has the correct pristine / empty state 1`] = `
<div
  class="tab results-tab"
>
  <div
    class="tabs"
  >
    
    <button
      class="tabs__action selected"
    >
      <!--v-if-->
      <img
        src=""
        width="16"
      />
      <span
        class="tabs__action--text"
      >
        puppeteer
      </span>
    </button>
    <button
      class="tabs__action"
    >
      <span>
        🎭
      </span>
      <!--v-if-->
      <span
        class="tabs__action--text"
      >
        playwright
      </span>
    </button>
    
  </div>
  <div
    class="content"
  >
    <pre>
              
      <code>
        No code yet...
      </code>
      
      
    </pre>
  </div>
</div>
`;

exports[`RecordingTab.vue it show a code box when there is code 1`] = `
<div
  class="tab results-tab"
>
  <div
    class="tabs"
  >
    
    <button
      class="tabs__action selected"
    >
      <!--v-if-->
      <img
        src=""
        width="16"
      />
      <span
        class="tabs__action--text"
      >
        puppeteer
      </span>
    </button>
    <button
      class="tabs__action"
    >
      <span>
        🎭
      </span>
      <!--v-if-->
      <span
        class="tabs__action--text"
      >
        playwright
      </span>
    </button>
    
  </div>
  <div
    class="content"
  >
    <pre>
              
      <code
        class="javascript hljs"
      >
        <span
          class="hljs-keyword"
        >
          await
        </span>
         page.click(
        <span
          class="hljs-string"
        >
          '.class'
        </span>
        )
      </code>
      
      
    </pre>
  </div>
</div>
`;


================================================
FILE: src/content-scripts/__tests__/attributes.spec.js
================================================
import puppeteer from 'puppeteer'
import { launchPuppeteerWithExtension } from '@/__tests__/helpers'
import { waitForAndGetEvents, cleanEventLog, startServer } from './helpers'

let server
let port
let browser
let page

describe('attributes', () => {
  beforeAll(async done => {
    const buildDir = '../../../dist'
    const fixture = './fixtures/attributes.html'
    {
      const { server: _s, port: _p } = await startServer(buildDir, fixture)
      server = _s
      port = _p
    }
    return done()
  }, 20000)

  afterAll(done => {
    server.close(() => {
      return done()
    })
  })

  beforeEach(async () => {
    browser = await launchPuppeteerWithExtension(puppeteer)
    page = await browser.newPage()
    await page.goto(`http://localhost:${port}/`)
    await cleanEventLog(page)
  })

  afterEach(async () => {
    browser.close()
  })

  test('it should load the content', async () => {
    const content = await page.$('#content-root')
    expect(content).toBeTruthy()
  })

  test('it should use data attributes throughout selector', async () => {
    await page.evaluate('window.eventRecorder._dataAttribute = "data-qa"')
    await page.click('span')

    const event = (await waitForAndGetEvents(page, 1))[0]
    expect(event.selector).toEqual(
      'body > #content-root > [data-qa="article-wrapper"] > [data-qa="article-body"] > span'
    )
  })

  test('it should use data attributes throughout selector even when id is set', async () => {
    await page.evaluate('window.eventRecorder._dataAttribute = "data-qa"')
    await page.click('#link')

    const event = (await waitForAndGetEvents(page, 1))[0]
    expect(event.selector).toEqual('[data-qa="link"]')
  })

  test('it should use id throughout selector when data attributes is not set', async () => {
    await page.evaluate('window.eventRecorder._dataAttribute = null')
    await page.click('#link')

    const event = (await waitForAndGetEvents(page, 1))[0]
    expect(event.selector).toEqual('#link')
  })
})


================================================
FILE: src/content-scripts/__tests__/fixtures/attributes.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>forms</title>
</head>
<body>
<div id="content-root">
  <div data-qa="article-wrapper" class="wrapper">
    <h1 data-qa="article-title" class="title">Lorem</h1>
    <div data-qa="article-body" class="body">
      <span>Read More...</span>
    </div>
  </div>
  <a href="#" id="link" data-qa="link">Click here</a>
</div>
<script src="./build/js/content-script.js" ></script>
</body>
</html>


================================================
FILE: src/content-scripts/__tests__/fixtures/forms.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>forms</title>
</head>
<body>
<form action="/handler" method="post">
  <fieldset>
    <legend>Inputs</legend>
    <div>
      <label>text input</label>
      <input type="text">
    </div>
    <div>
      <label for="msg">text area</label>
      <textarea id="msg"></textarea>
    </div>
    <div>
      <label>radio</label>
      <input type="radio" id="radioChoice1"
             name="contact" value="radioChoice1">
      <label>radioChoice1</label>

      <input type="radio" id="radioChoice2"
             name="contact" value="radioChoice2">
      <label>radioChoice2</label>

      <input type="radio" id="radioChoice3"
             name="contact" value="radioChoice3">
      <label>radioChoice3</label>
    </div>
  </fieldset>
  <fieldset>
    <legend>Select</legend>
    <select>
      <option value="">--Please choose an option--</option>
      <option value="dog">Dog</option>
      <option value="cat">Cat</option>
      <option value="hamster">Hamster</option>
      <option value="parrot">Parrot</option>
      <option value="spider">Spider</option>
      <option value="goldfish">Goldfish</option>
    </select>
  </fieldset>

  <fieldset>
    <legend>Checkboxes</legend>
    <div>
      <input id="checkbox1" type="checkbox" name="interest" value="checkbox1">
      <label>Coding</label>
    </div>
    <div>
      <input id="checkbox2" type="checkbox" name="interest" value="checkbox2">
      <label>Music</label>
    </div>
  </fieldset>
  <div>
    <button type="submit">Submit</button>
  </div>
</form>
<script src="./build/js/content-script.js" ></script>
</body>
</html>


================================================
FILE: src/content-scripts/__tests__/forms.spec.js
================================================
import puppeteer from 'puppeteer'
import _ from 'lodash'
import { launchPuppeteerWithExtension } from '@/__tests__/helpers'
import { waitForAndGetEvents, cleanEventLog, startServer } from './helpers'

let server
let port
let browser
let page

describe('forms', () => {
  beforeAll(async done => {
    const buildDir = '../../../dist'
    const fixture = './fixtures/forms.html'
    {
      const { server: _s, port: _p } = await startServer(buildDir, fixture)
      server = _s
      port = _p
    }
    return done()
  }, 20000)

  afterAll(done => {
    server.close(() => {
      return done()
    })
  })

  beforeEach(async () => {
    browser = await launchPuppeteerWithExtension(puppeteer)
    page = await browser.newPage()
    await page.goto(`http://localhost:${port}/`)
    await cleanEventLog(page)
  })

  afterEach(async () => {
    browser.close()
  })

  const tab = 1
  const change = 1
  test('it should load the form', async () => {
    const form = await page.$('form')
    expect(form).toBeTruthy()
  })

  test('it should record text input elements', async () => {
    const string = 'I like turtles'
    await page.type('input[type="text"]', string)
    await page.keyboard.press('Tab')

    const eventLog = await waitForAndGetEvents(page, string.length + tab + change)
    const event = _.find(eventLog, e => {
      return e.action === 'keydown' && e.keyCode === 9
    })
    expect(event.value).toEqual(string)
  })

  test('it should record textarea elements', async () => {
    const string = 'I like turtles\n but also cats'
    await page.type('textarea', string)
    await page.keyboard.press('Tab')

    const eventLog = await waitForAndGetEvents(page, string.length + tab + change)
    const event = _.find(eventLog, e => {
      return e.action === 'keydown' && e.keyCode === 9
    })
    expect(event.value).toEqual(string)
  })

  test('it should record radio input elements', async () => {
    await page.click('#radioChoice1')
    await page.click('#radioChoice3')
    const eventLog = await waitForAndGetEvents(page, 2 + 2 * change)
    expect(eventLog[0].value).toEqual('radioChoice1')
    expect(eventLog[2].value).toEqual('radioChoice3')
  })

  test('it should record select and option elements', async () => {
    await page.select('select', 'hamster')
    const eventLog = await waitForAndGetEvents(page, 1)
    expect(eventLog[0].value).toEqual('hamster')
    expect(eventLog[0].tagName).toEqual('SELECT')
  })

  test('it should record checkbox input elements', async () => {
    await page.click('#checkbox1')
    await page.click('#checkbox2')
    const eventLog = await waitForAndGetEvents(page, 2 + 2 * change)
    expect(eventLog[0].value).toEqual('checkbox1')
    expect(eventLog[2].value).toEqual('checkbox2')
  })
})


================================================
FILE: src/content-scripts/__tests__/helpers.js
================================================
import express from 'express'
import path from 'path'

export const waitForAndGetEvents = async function(page, amount) {
  await waitForRecorderEvents(page, amount)
  return getEventLog(page)
}

export const waitForRecorderEvents = function(page, amount) {
  return page.waitForFunction(`window.eventRecorder._getEventLog().length >= ${amount || 1}`)
}

export const getEventLog = function(page) {
  return page.evaluate(() => {
    return window.eventRecorder._getEventLog()
  })
}

export const cleanEventLog = function(page) {
  return page.evaluate(() => {
    return window.eventRecorder._clearEventLog()
  })
}

export const startServer = function(buildDir, file) {
  return new Promise(resolve => {
    const app = express()
    app.use('/build', express.static(path.join(__dirname, buildDir)))
    app.get('/', (req, res) => {
      res.status(200).sendFile(file, { root: __dirname })
    })
    let server
    let port
    const retry = e => {
      if (e.code === 'EADDRINUSE') {
        setTimeout(() => connect, 1000)
      }
    }
    const connect = () => {
      port = 0 | (Math.random() * 1000 + 3000)
      server = app.listen(port)
      server.once('error', retry)
      server.once('listening', () => {
        return resolve({ server, port })
      })
    }
    connect()
  })
}


================================================
FILE: src/content-scripts/__tests__/screenshot-controller.spec.js
================================================
import UIController from '../shooter'

// this test NEEDS to come first because of shitty JSDOM.
// See https://github.com/facebook/jest/issues/1224
it('Registers mouse events', () => {
  jest.useFakeTimers()

  document.body.innerHTML =
    '<div>' + '  <div id="username">UserName</div>' + '  <button id="button"></button>' + '</div>'

  const uic = new UIController()
  uic.showSelector()

  const handleClick = jest.fn()
  uic.on('click', handleClick)

  const el = document.querySelector('#username')
  el.click()

  jest.runAllTimers()

  expect(setTimeout).toHaveBeenCalledTimes(1)
  expect(handleClick).toHaveBeenCalled()
})

it('Shows and hides the selector', () => {
  const uic = new UIController()

  uic.showSelector()
  let overlay = document.querySelector('.headlessRecorderOverlay')
  let outline = document.querySelector('.headlessRecorderOutline')

  expect(overlay).toBeDefined()
  expect(outline).toBeDefined()

  uic.hideSelector()
  overlay = document.querySelector('.headlessRecorderOverlay')
  outline = document.querySelector('.headlessRecorderOutline')

  expect(overlay).toBeNull()
  expect(outline).toBeNull()
})


================================================
FILE: src/content-scripts/controller.js
================================================
import { overlayActions } from '@/modules/overlay/constants'
import { popupActions, recordingControls, isDarkMode } from '@/services/constants'

import storage from '@/services/storage'
import browser from '@/services/browser'

import Shooter from '@/modules/shooter'

export default class HeadlessController {
  constructor({ overlay, recorder, store }) {
    this.backgroundListener = null

    this.store = store
    this.shooter = null
    this.overlay = overlay
    this.recorder = recorder
  }

  async init() {
    const { options } = await storage.get(['options'])

    const darkMode = options && options.extension ? options.extension.darkMode : isDarkMode()
    const { dataAttribute } = options ? options.code : {}

    this.store.commit('setDarkMode', darkMode)
    this.store.commit('setDataAttribute', dataAttribute)

    this.recorder.init(() => this.listenBackgroundMessages())
  }

  listenBackgroundMessages() {
    this.backgroundListener = this.backgroundListener || this.handleBackgroundMessages.bind(this)
    chrome.runtime.onMessage.addListener(this.backgroundListener)
  }

  async handleBackgroundMessages(msg) {
    if (!msg?.action) {
      return
    }

    switch (msg.action) {
      case overlayActions.TOGGLE_SCREENSHOT_MODE:
        this.handleScreenshot(false)
        break

      case overlayActions.TOGGLE_SCREENSHOT_CLIPPED_MODE:
        this.handleScreenshot(true)
        break

      case overlayActions.CLOSE_SCREENSHOT_MODE:
        this.cancelScreenshot()
        break

      case overlayActions.TOGGLE_OVERLAY:
        msg?.value?.open ? this.overlay.mount(msg.value) : this.overlay.unmount()
        break

      case popupActions.STOP:
        this.store.commit('close')
        break

      case popupActions.PAUSE:
        this.store.commit('pause')
        break

      case popupActions.UN_PAUSE:
        this.store.commit('unpause')
        break

      case 'CODE':
        await browser.copyToClipboard(msg.value)
        this.store.commit('showCopy')
        break
    }
  }

  handleScreenshot(isClipped) {
    this.recorder.disableClickRecording()
    this.shooter = new Shooter({ isClipped, store: this.store })

    this.shooter.addCameraIcon()

    this.store.state.screenshotMode
      ? this.shooter.startScreenshotMode()
      : this.shooter.stopScreenshotMode()

    this.shooter.on('click', ({ selector }) => {
      this.store.commit('stopScreenshotMode')

      this.shooter.showScreenshotEffect()
      this.recorder._sendMessage({ control: recordingControls.GET_SCREENSHOT, value: selector })
      this.recorder.enableClickRecording()
    })
  }

  cancelScreenshot() {
    if (!this.store.state.screenshotMode) {
      return
    }

    this.store.commit('stopScreenshotMode')
    this.recorder.enableClickRecording()
  }
}


================================================
FILE: src/content-scripts/index.js
================================================
import store from '@/store'

import Overlay from '@/modules/overlay'
import Recorder from '@/modules/recorder'

import HeadlessController from '@/content-scripts/controller'

window.headlessRecorder = new HeadlessController({
  overlay: new Overlay({ store }),
  recorder: new Recorder({ store }),
  store,
})

window.headlessRecorder.init()


================================================
FILE: src/manifest.json
================================================
{
  "name": "Headless Recorder",
  "version": "1.0.0",
  "manifest_version": 2,
  "description": "A Chrome extension for recording browser interaction and generating Puppeteer & Playwright scripts",
  "default_locale": "en",
  "permissions": [
    "storage",
    "webNavigation",
    "activeTab",
    "cookies",
    "*://*/"
  ],
  "icons" : {
    "16": "icons/16.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  },
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
  "background": {
    "scripts": [
      "js/background.js"
    ],
    "persistent": false
  },
  "browser_action": {
    "default_popup": "popup.html",
    "default_title": "__MSG_extName__",
    "default_icon": {
      "19": "icons/19.png",
      "38": "icons/38.png"
    }
  },
  "options_ui": {
    "page": "options.html",
    "browser_style": true,
    "open_in_tab": true
  },
  "web_accessible_resources": [
    "icons/dark/play.svg",
    "icons/light/play.svg",
    "icons/dark/pause.svg",
    "icons/light/pause.svg",
    "icons/dark/screen.svg",
    "icons/light/screen.svg",
    "icons/dark/clip.svg",
    "icons/light/clip.svg",
    "icons/dark/sync.svg",
    "icons/light/sync.svg",
    "icons/dark/duplicate.svg",
    "icons/light/duplicate.svg"
  ]
}


================================================
FILE: src/modules/code-generator/__tests__/playwright-code-generator.spec.js
================================================
import PlaywrightCodeGenerator from '../playwright'

describe('PlaywrightCodeGenerator', () => {
  test('it should generate nothing when there are no events', () => {
    const events = []
    const codeGenerator = new PlaywrightCodeGenerator()
    expect(codeGenerator._parseEvents(events)).toBeFalsy()
  })

  test('it generates a page.selectOption() only for select dropdowns', () => {
    const events = [
      {
        action: 'change',
        selector: 'select#animals',
        tagName: 'SELECT',
        value: 'hamster',
      },
    ]
    const codeGenerator = new PlaywrightCodeGenerator()
    expect(codeGenerator._parseEvents(events)).toContain(
      `await page.selectOption('${events[0].selector}', '${events[0].value}')`
    )
  })
})


================================================
FILE: src/modules/code-generator/__tests__/puppeteer-code-generator.spec.js
================================================
import PuppeteerCodeGenerator from '../puppeteer'
import { headlessActions } from '@/services/constants'

describe('PuppeteerCodeGenerator', () => {
  test('it should generate nothing when there are no events', () => {
    const events = []
    const codeGenerator = new PuppeteerCodeGenerator()
    expect(codeGenerator._parseEvents(events)).toBeFalsy()
  })

  test('it generates a page.select() only for select dropdowns', () => {
    const events = [
      {
        action: 'change',
        selector: 'select#animals',
        tagName: 'SELECT',
        value: 'hamster',
      },
    ]
    const codeGenerator = new PuppeteerCodeGenerator()
    expect(codeGenerator._parseEvents(events)).toContain(
      "await page.select('select#animals', 'hamster')"
    )
  })

  test('it generates the correct waitForNavigation code', () => {
    const events = [{ action: 'click', selector: 'a.link' }, { action: headlessActions.NAVIGATION }]
    const codeGenerator = new PuppeteerCodeGenerator()
    const code = codeGenerator._parseEvents(events)
    const lines = code.split('\n')
    expect(lines[1].trim()).toEqual('const navigationPromise = page.waitForNavigation()')
    expect(lines[4].trim()).toEqual("await page.click('a.link')")
    expect(lines[6].trim()).toEqual('await navigationPromise')
  })

  test('it does not generate waitForNavigation code when turned off', () => {
    const events = [{ action: 'navigation*' }, { action: 'click', selector: 'a.link' }]
    const codeGenerator = new PuppeteerCodeGenerator({
      waitForNavigation: false,
    })
    expect(codeGenerator._parseEvents(events)).not.toContain(
      'const navigationPromise = page.waitForNavigation()\n'
    )
    expect(codeGenerator._parseEvents(events)).not.toContain('await navigationPromise\n')
  })

  test('it generates the correct waitForSelector code before clicks', () => {
    const events = [{ action: 'click', selector: 'a.link' }]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)

    expect(result).toContain("await page.waitForSelector('a.link')")
    expect(result).toContain("await page.click('a.link')")
  })

  test('it does not generate the waitForSelector code before clicks when turned off', () => {
    const events = [{ action: 'click', selector: 'a.link' }]
    const codeGenerator = new PuppeteerCodeGenerator({
      waitForSelectorOnClick: false,
    })
    const result = codeGenerator._parseEvents(events)

    expect(result).not.toContain("await page.waitForSelector('a.link')")
    expect(result).toContain("await page.click('a.link')")
  })

  test('it uses the default page frame when events originate from frame 0', () => {
    const events = [
      {
        action: 'click',
        selector: 'a.link',
        frameId: 0,
        frameUrl: 'https://some.site.com',
      },
    ]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)
    expect(result).toContain("await page.click('a.link')")
  })

  test('it uses a different frame when events originate from an iframe', () => {
    const events = [
      {
        action: 'click',
        selector: 'a.link',
        frameId: 123,
        frameUrl: 'https://some.iframe.com',
      },
    ]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)
    expect(result).toContain("await frame_123.click('a.link')")
  })

  test('it adds a frame selection preamble when events originate from an iframe', () => {
    const events = [
      {
        action: 'click',
        selector: 'a.link',
        frameId: 123,
        frameUrl: 'https://some.iframe.com',
      },
    ]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)
    expect(result).toContain('let frames = await page.frames()')
    expect(result).toContain(
      "const frame_123 = frames.find(f => f.url() === 'https://some.iframe.com'"
    )
  })

  test('it generates the correct current page screenshot code', () => {
    const events = [{ action: headlessActions.SCREENSHOT }]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)

    expect(result).toContain("await page.screenshot({ path: 'screenshot_1.png' })")
  })

  test('it generates the correct clipped page screenshot code', () => {
    const events = [
      {
        action: headlessActions.SCREENSHOT,
        value: { x: '10px', y: '300px', width: '800px', height: '600px' },
      },
    ]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)

    expect(result).toContain(
      "await page.screenshot({ path: 'screenshot_1.png', clip: { x: 10, y: 300, width: 800, height: 600 } })"
    )
  })

  test('it generates the correct escaped value', () => {
    const events = [
      {
        action: 'keydown',
        keyCode: 9,
        selector: 'input.value',
        value: "hello');console.log('world",
      },
    ]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)

    expect(result).toContain("await page.type('input.value', 'hello\\');console.log(\\'world')")
  })

  test('it generates the correct escaped value with backslash', () => {
    const events = [{ action: 'click', selector: 'button.\\hello\\' }]
    const codeGenerator = new PuppeteerCodeGenerator()
    const result = codeGenerator._parseEvents(events)

    expect(result).toContain("await page.click('button.\\\\hello\\\\')")
  })
})


================================================
FILE: src/modules/code-generator/base-generator.js
================================================
import Block from '@/modules/code-generator/block'
import { headlessActions, eventsToRecord } from '@/modules/code-generator/constants'

export const defaults = {
  wrapAsync: false,
  headless: true,
  waitForNavigation: true,
  waitForSelectorOnClick: true,
  blankLinesBetweenBlocks: true,
  dataAttribute: '',
  showPlaywrightFirst: true,
  keyCode: 9,
}

export default class BaseGenerator {
  constructor(options) {
    this._options = Object.assign(defaults, options)
    this._blocks = []
    this._frame = 'page'
    this._frameId = 0
    this._allFrames = {}
    this._screenshotCounter = 0

    this._hasNavigation = false
  }

  generate() {
    throw new Error('Not implemented.')
  }

  _getHeader() {
    let hdr = this._options.wrapAsync ? this._wrappedHeader : this._header
    hdr = this._options.headless ? hdr : hdr?.replace('launch()', 'launch({ headless: false })')
    return hdr
  }

  _getFooter() {
    return this._options.wrapAsync ? this._wrappedFooter : this._footer
  }

  _parseEvents(events) {
    let result = ''

    if (!events) return result

    for (let i = 0; i < events.length; i++) {
      const { action, selector, value, href, keyCode, tagName, frameId, frameUrl } = events[i]
      const escapedSelector = selector ? selector?.replace(/\\/g, '\\\\') : selector

      // we need to keep a handle on what frames events originate from
      this._setFrames(frameId, frameUrl)

      switch (action) {
        case 'keydown':
          if (keyCode === this._options.keyCode) {
            this._blocks.push(this._handleKeyDown(escapedSelector, value, keyCode))
          }
          break
        case 'click':
          this._blocks.push(this._handleClick(escapedSelector, events))
          break
        case 'change':
          if (tagName === 'SELECT') {
            this._blocks.push(this._handleChange(escapedSelector, value))
          }
          break
        case headlessActions.GOTO:
          this._blocks.push(this._handleGoto(href, frameId))
          break
        case headlessActions.VIEWPORT:
          this._blocks.push(this._handleViewport(value.width, value.height))
          break
        case headlessActions.NAVIGATION:
          this._blocks.push(this._handleWaitForNavigation())
          this._hasNavigation = true
          break
        case headlessActions.SCREENSHOT:
          this._blocks.push(this._handleScreenshot(value))
          break
      }
    }

    if (this._hasNavigation && this._options.waitForNavigation) {
      const block = new Block(this._frameId, {
        type: headlessActions.NAVIGATION_PROMISE,
        value: 'const navigationPromise = page.waitForNavigation()',
      })
      this._blocks.unshift(block)
    }

    this._postProcess()

    const indent = this._options.wrapAsync ? '  ' : ''
    const newLine = `\n`

    for (let block of this._blocks) {
      const lines = block.getLines()
      for (let line of lines) {
        result += indent + line.value + newLine
      }
    }

    return result
  }

  _setFrames(frameId, frameUrl) {
    if (frameId && frameId !== 0) {
      this._frameId = frameId
      this._frame = `frame_${frameId}`
      this._allFrames[frameId] = frameUrl
    } else {
      this._frameId = 0
      this._frame = 'page'
    }
  }

  _postProcess() {
    // when events are recorded from different frames, we want to add a frame setter near the code that uses that frame
    if (Object.keys(this._allFrames).length > 0) {
      this._postProcessSetFrames()
    }

    if (this._options.blankLinesBetweenBlocks && this._blocks.length > 0) {
      this._postProcessAddBlankLines()
    }
  }

  _handleKeyDown(selector, value) {
    const block = new Block(this._frameId)
    block.addLine({
      type: eventsToRecord.KEYDOWN,
      value: `await ${this._frame}.type('${selector}', '${this._escapeUserInput(value)}')`,
    })
    return block
  }

  _handleClick(selector) {
    const block = new Block(this._frameId)
    if (this._options.waitForSelectorOnClick) {
      block.addLine({
        type: eventsToRecord.CLICK,
        value: `await ${this._frame}.waitForSelector('${selector}')`,
      })
    }
    block.addLine({
      type: eventsToRecord.CLICK,
      value: `await ${this._frame}.click('${selector}')`,
    })
    return block
  }

  _handleChange(selector, value) {
    return new Block(this._frameId, {
      type: eventsToRecord.CHANGE,
      value: `await ${this._frame}.select('${selector}', '${value}')`,
    })
  }

  _handleGoto(href) {
    return new Block(this._frameId, {
      type: headlessActions.GOTO,
      value: `await ${this._frame}.goto('${href}')`,
    })
  }

  _handleViewport() {
    throw new Error('Not implemented.')
  }

  _handleScreenshot(value) {
    this._screenshotCounter += 1

    if (value) {
      return new Block(this._frameId, {
        type: headlessActions.SCREENSHOT,
        value: `const element${this._screenshotCounter} = await page.$('${value}')
await element${this._screenshotCounter}.screenshot({ path: 'screenshot_${this._screenshotCounter}.png' })`,
      })
    }

    return new Block(this._frameId, {
      type: headlessActions.SCREENSHOT,
      value: `await ${this._frame}.screenshot({ path: 'screenshot_${this._screenshotCounter}.png', fullPage: true })`,
    })
  }

  _handleWaitForNavigation() {
    const block = new Block(this._frameId)
    if (this._options.waitForNavigation) {
      block.addLine({
        type: headlessActions.NAVIGATION,
        value: `await navigationPromise`,
      })
    }
    return block
  }

  _postProcessSetFrames() {
    for (let [i, block] of this._blocks.entries()) {
      const lines = block.getLines()
      for (let line of lines) {
        if (line.frameId && Object.keys(this._allFrames).includes(line.frameId.toString())) {
          const declaration = `const frame_${line.frameId} = frames.find(f => f.url() === '${
            this._allFrames[line.frameId]
          }')`
          this._blocks[i].addLineToTop({
            type: headlessActions.FRAME_SET,
            value: declaration,
          })
          this._blocks[i].addLineToTop({
            type: headlessActions.FRAME_SET,
            value: 'let frames = await page.frames()',
          })
          delete this._allFrames[line.frameId]
          break
        }
      }
    }
  }

  _postProcessAddBlankLines() {
    let i = 0
    while (i <= this._blocks.length) {
      const blankLine = new Block()
      blankLine.addLine({ type: null, value: '' })
      this._blocks.splice(i, 0, blankLine)
      i += 2
    }
  }

  _escapeUserInput(value) {
    return value?.replace(/\\/g, '\\\\')?.replace(/'/g, "\\'")
  }
}


================================================
FILE: src/modules/code-generator/block.js
================================================
export default class Block {
  constructor(frameId, line) {
    this._lines = []
    this._frameId = frameId

    if (line) {
      line.frameId = this._frameId
      this._lines.push(line)
    }
  }

  addLineToTop(line) {
    line.frameId = this._frameId
    this._lines.unshift(line)
  }

  addLine(line) {
    line.frameId = this._frameId
    this._lines.push(line)
  }

  getLines() {
    return this._lines
  }
}


================================================
FILE: src/modules/code-generator/constants.js
================================================
export const headlessActions = {
  GOTO: 'GOTO',
  VIEWPORT: 'VIEWPORT',
  WAITFORSELECTOR: 'WAITFORSELECTOR',
  NAVIGATION: 'NAVIGATION',
  NAVIGATION_PROMISE: 'NAVIGATION_PROMISE',
  FRAME_SET: 'FRAME_SET',
  SCREENSHOT: 'SCREENSHOT',
}

export const eventsToRecord = {
  CLICK: 'click',
  DBLCLICK: 'dblclick',
  CHANGE: 'change',
  KEYDOWN: 'keydown',
  SELECT: 'select',
  SUBMIT: 'submit',
  LOAD: 'load',
  UNLOAD: 'unload',
}

export const headlessTypes = {
  PUPPETEER: 'puppeteer',
  PLAYWRIGHT: 'playwright',
}


================================================
FILE: src/modules/code-generator/index.js
================================================
import PuppeteerCodeGenerator from '@/modules/code-generator/puppeteer'
import PlaywrightCodeGenerator from '@/modules/code-generator/playwright'

export default class CodeGenerator {
  constructor(options = {}) {
    this.puppeteerGenerator = new PuppeteerCodeGenerator(options)
    this.playwrightGenerator = new PlaywrightCodeGenerator(options)
  }

  generate(recording) {
    return {
      puppeteer: this.puppeteerGenerator.generate(recording),
      playwright: this.playwrightGenerator.generate(recording),
    }
  }
}


================================================
FILE: src/modules/code-generator/playwright.js
================================================
import Block from '@/modules/code-generator/block'
import { headlessActions } from '@/modules/code-generator/constants'
import BaseGenerator from '@/modules/code-generator/base-generator'

const importPlaywright = `const { chromium } = require('playwright');\n`

const header = `const browser = await chromium.launch()
const page = await browser.newPage()`

const footer = `await browser.close()`

const wrappedHeader = `(async () => {
  ${header}\n`

const wrappedFooter = `  ${footer}
})()`

export default class PlaywrightCodeGenerator extends BaseGenerator {
  constructor(options) {
    super(options)
    this._header = header
    this._footer = footer
    this._wrappedHeader = wrappedHeader
    this._wrappedFooter = wrappedFooter
  }

  generate(events) {
    return importPlaywright + this._getHeader() + this._parseEvents(events) + this._getFooter()
  }

  _handleViewport(width, height) {
    return new Block(this._frameId, {
      type: headlessActions.VIEWPORT,
      value: `await ${this._frame}.setViewportSize({ width: ${width}, height: ${height} })`,
    })
  }

  _handleChange(selector, value) {
    return new Block(this._frameId, {
      type: headlessActions.CHANGE,
      value: `await ${this._frame}.selectOption('${selector}', '${value}')`,
    })
  }
}


================================================
FILE: src/modules/code-generator/puppeteer.js
================================================
import Block from '@/modules/code-generator/block'
import { headlessActions } from '@/modules/code-generator/constants'
import BaseGenerator from '@/modules/code-generator/base-generator'

const importPuppeteer = `const puppeteer = require('puppeteer');\n`

const header = `const browser = await puppeteer.launch()
const page = await browser.newPage()`

const footer = `await browser.close()`

const wrappedHeader = `(async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()\n`

const wrappedFooter = `  await browser.close()
})()`

export default class PuppeteerCodeGenerator extends BaseGenerator {
  constructor(options) {
    super(options)
    this._header = header
    this._footer = footer
    this._wrappedHeader = wrappedHeader
    this._wrappedFooter = wrappedFooter
  }

  generate(events) {
    return importPuppeteer + this._getHeader() + this._parseEvents(events) + this._getFooter()
  }

  _handleViewport(width, height) {
    return new Block(this._frameId, {
      type: headlessActions.VIEWPORT,
      value: `await ${this._frame}.setViewport({ width: ${width}, height: ${height} })`,
    })
  }
}


================================================
FILE: src/modules/overlay/Overlay.vue
================================================
<template>
  <nav
    v-show="!screenshotMode"
    :class="{
      'hr-event-recorded': hasRecorded && !isPaused && !isStopped,
      dark: darkMode,
      hide: !show,
    }"
  >
    <template v-if="isStopped">
      <div class="hr-success-message">
        <h3>Recording finished!</h3>
        <p>You can copy the code to clipboard right away!</p>
      </div>
      <div class="hr-success-bar">
        <button @click="copy" class="hr-btn-large" style="width: 151px;">
          <img
            v-show="!isCopying"
            width="16"
            height="16"
            :src="getIcon('duplicate')"
            alt="copy to clipboard"
          />
          <span v-show="!isCopying">Copy to clipboard</span>
          <span v-show="isCopying">Copied!</span>
        </button>
        <button @click="restart" class="hr-btn-large">
          <img width="16" height="16" :src="getIcon('sync')" alt="restart recording" />
          Restart Recording
        </button>
        <button @click="close" class="btn-close">
          &times;
        </button>
      </div>
    </template>
    <template v-else>
      <div class="hr-rec" v-show="!isPaused">
        <span class="hr-red-dot"></span>
        REC
      </div>
      <span class="hr-shortcut">
        alt + k to hide
      </span>
      <button
        class="hr-btn"
        title="stop"
        @click="stop"
        v-tippy="{ content: 'Stop Recording', appendTo: 'parent' }"
      >
        <div class="hr-stop-square"></div>
      </button>
      <button
        class="hr-btn"
        title="pause"
        @click="pause"
        v-tippy="{ content: isPaused ? 'Resume Recording' : 'Pause Recording', appendTo: 'parent' }"
      >
        <img v-show="isPaused" width="27" height="27" :src="getIcon('play')" alt="play" />
        <img v-show="!isPaused" width="27" height="27" :src="getIcon('pause')" alt="pause" />
      </button>
      <div class="hr-separator"></div>
      <button
        :disabled="isPaused"
        class="hr-btn-big"
        @click.prevent="fullScreenshot"
        v-tippy="{ content: 'Full Screenshot (alt+shift+F)', appendTo: 'parent' }"
      >
        <img width="27" height="27" :src="getIcon('screen')" alt="full page sreenshot" />
      </button>
      <button
        :disabled="isPaused"
        class="hr-btn-big"
        @click.prevent="clippedScreenshot"
        v-tippy="{ content: 'Element Screenshot (alt+shift+E)', appendTo: 'parent' }"
      >
        <img width="27" height="27" :src="getIcon('clip')" alt="clipped sreenshot" />
      </button>
      <div class="hr-separator"></div>
      <span class="hr-current-selector">
        {{ currentSelector }}
      </span>
    </template>
  </nav>
</template>

<script>
import { directive } from 'vue-tippy'
import 'tippy.js/dist/tippy.css'

import { mapState, mapMutations } from 'vuex'

export default {
  name: 'Overlay',
  directives: { tippy: directive },

  data() {
    return {
      currentSelector: '',
      show: true,
    }
  },

  computed: {
    ...mapState([
      'isPaused',
      'isStopped',
      'screenshotMode',
      'darkMode',
      'hasRecorded',
      'isCopying',
      'recording',
    ]),
  },

  mounted() {
    window.document.body.addEventListener('keyup', this.keyupListener, false)
  },

  beforeUnmount() {
    window.document.body.removeEventListener('keyup', this.keyupListener, false)
  },

  methods: {
    ...mapMutations(['copy', 'stop', 'close', 'restart']),

    getIcon(icon) {
      return browser.runtime.getURL(`icons/${this.darkMode ? 'dark' : 'light'}/${icon}.svg`)
    },

    toggle() {
      this.show = !this.show
    },

    pause() {
      this.isPaused ? this.$store.commit('unpause') : this.$store.commit('pause')
    },

    fullScreenshot() {
      this.$store.commit('startScreenshotMode', false)
    },

    clippedScreenshot() {
      this.$store.commit('startScreenshotMode', true)
    },

    keyupListener(e) {
      if (!e.altKey) {
        return
      }

      if (e.key === 'k') {
        this.toggle()
      }

      if (e.key === 'F') {
        this.fullScreenshot()
      }

      if (e.key === 'E') {
        this.clippedScreenshot()
      }
    },
  },
}
</script>

<style lang="scss">
@import '../../assets/animations.css';

$namespace: 'hr';

#headless-recorder-overlay {
  .#{$namespace}-button-open {
    position: fixed;
    bottom: 10px;
    left: 0;
    right: 0;
  }

  button {
    border: none;
    margin: 0;
    padding: 0;

    overflow: visible;
    background: transparent;
    color: inherit;
    font: inherit;
    line-height: normal;

    display: flex;
    justify-content: center;
    align-items: center;
    cursor: pointer;
    margin-right: 10px;
  }

  nav {
    font-family: sans-serif;
    box-sizing: border-box;
    animation-name: slideup;
    border: solid 2px #f9fafc;
    animation-duration: 0.3s;
    animation-iteration-count: 1;
    animation-timing-function: ease-in-out;
    display: flex;
    align-items: center;
    z-index: 2147483647;
    position: fixed;
    bottom: 10px;
    left: 0;
    right: 0;
    margin-left: auto;
    margin-right: auto;
    font-size: 12px;
    color: #1f2d3d;
    padding: 20px 16px;
    transition: all 0.1s ease;
    width: 828px;
    height: 72px;
    background: #f9fafc;
    box-shadow: 0px 5px 25px rgba(0, 0, 0, 0.15);
    border-radius: 6px;

    &.#{$namespace}-event-recorded {
      border: solid 2px #45c8f1 !important;
      transition: all 0.1s linear;
    }

    button {
      &.#{$namespace}-btn-big {
        padding: 5px 15px;
        background: #eff2f7;
        border-radius: 3px;

        &:disabled {
          cursor: not-allowed;
        }
      }

      &.#{$namespace}-btn {
        padding: 5px 0;
      }

      &.#{$namespace}-btn-large {
        border-radius: 3px;
        background: #eff2f7;
        padding: 9px 17px 9px 8px;
        color: #1f2d3d;
        font-weight: 600;
        margin-right: 16px;

        &:last-of-type {
          margin-right: 0;
        }

        &:hover {
          background: #e0e6ed;
        }

        img {
          margin-right: 8px;
        }
      }

      &.#{$namespace}-btn-close {
        font-size: 18px;
        color: #161616;
        margin-right: 0;
      }
    }

    .#{$namespace}-shortcut {
      color: #8492a6;
      margin-right: 0;
      font-family: sans-serif;
      position: absolute;
      top: 4px;
      right: 4px;
    }

    .#{$namespace}-rec {
      font-family: sans-serif;
      animation: pulse 2s infinite;
      font-size: 12px;
      position: absolute;
      top: 4px;
      left: 4px;
      font-weight: 600;
      color: #ff4949;
      text-transform: uppercase;

      .#{$namespace}-red-dot {
        display: inline-block;
        border-radius: 50%;
        width: 9px;
        height: 9px;
        background: #ff4949;
      }
    }

    .#{$namespace}-separator {
      width: 1px;
      height: 32px;
      background: #e0e6ed;
      margin-right: 0.8rem;
    }

    .#{$namespace}-stop-square {
      width: 24px;
      height: 24px;
      border-radius: 3px;
      background-color: #1f2d3d;
    }

    .#{$namespace}-current-selector {
      font-weight: 500;
      font-size: 10px;
      line-height: 20px;
      font-family: monospace;
    }

    .#{$namespace}-success-bar {
      display: flex;
      width: 60%;
      justify-content: flex-end;
    }

    .#{$namespace}-success-message {
      width: 40%;

      h3 {
        font-size: 14px;
        font-weight: 600;
        margin: 0;
        color: #1f2d3d;
      }

      p {
        font-size: 12px;
        margin: 0;
        color: #3c4858;
      }
    }

    .tippy-box {
      box-shadow: 0px 5px 25px rgba(0, 0, 0, 0.15);
      margin-top: -45px;
      color: #1f2d3d;
      background: #f9fafc;
      border-radius: 4px;
    }

    .tippy-arrow {
      color: #f9fafc;
    }
  }

  nav.dark {
    background: #161616;
    border: solid 2px #161616;
    color: #f9fafc;

    button {
      &.#{$namespace}-btn-big {
        padding: 5px 15px;
        background: #2e2e2e;
        border-radius: 3px;
      }

      &.#{$namespace}-btn-large {
        background: #1f2d3d;
        color: #f9fafc;

        &:hover {
          background: #474747;
        }
      }

      &.#{$namespace}-btn-close,
      &.#{$namespace}-btn-label,
      &.#{$namespace}-btn-up {
        color: #fff;
      }

      &.#{$namespace}-btn-up {
        background: #161616;
      }
    }

    .#{$namespace}-success-message {
      h3 {
        color: #fff;
      }

      p {
        color: #e0e6ed;
      }
    }

    .#{$namespace}-separator {
      background: #2e2e2e;
    }

    .#{$namespace}-stop-square {
      background-color: #f9fafc;
    }

    .tippy-box {
      color: #f9fafc;
      background: #161616;
    }

    .tippy-arrow {
      color: #161616;
    }
  }

  nav.hide {
    transform: translateY(82px) !important;
  }
}
</style>


================================================
FILE: src/modules/overlay/Selector.vue
================================================
<template>
  <div class="overlay">
    <div :class="selectorClass" ref="selector"></div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  data() {
    return {
      overlay: null,
      selector: null,
      element: null,
      scrolling: false,
      dimensions: {},
    }
  },

  computed: {
    ...mapState(['screenshotClippedMode', 'screenshotMode', 'isStopped']),

    selectorClass() {
      if (this.isStopped) {
        return ''
      }

      if (!this.screenshotMode || this.screenshotClippedMode) {
        return this.scrolling ? 'hide selector' : 'selector'
      }

      return ''
    },
  },

  methods: {
    move(e, skippedSelectors = []) {
      if (this.element === e.target) {
        return
      }

      this.element = e.target

      if (skippedSelectors.includes(this.element.id)) {
        return
      }

      this.dimensions.top = -window.scrollY
      this.dimensions.left = -window.scrollX

      let elem = e.target

      while (elem && elem !== document.body) {
        this.dimensions.top += elem.offsetTop
        this.dimensions.left += elem.offsetLeft
        elem = elem.offsetParent
      }

      this.dimensions.width = this.element.offsetWidth + 2
      this.dimensions.height = this.element.offsetHeight + 2

      this.$refs.selector.style.top = this.dimensions.top - 2 + 'px'
      this.$refs.selector.style.left = this.dimensions.left - 2 + 'px'
      this.$refs.selector.style.width = this.dimensions.width + 'px'
      this.$refs.selector.style.height = this.dimensions.height + 'px'
    },

    // TODO: Integrate shooter with selector
    click(e) {
      setTimeout(() => {
        let clip = null

        if (this.$refs.selector) {
          clip = {
            x: this.$refs.selector.style.left,
            y: this.$refs.selector.style.top,
            width: this.$refs.selector.style.width,
            height: this.$refs.selector.style.height,
          }
        }

        this.$emit('click', { clip, raw: e })
      }, 100)
    },
  },
}
</script>

<style scoped>
.overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 2147483646;
}

.selector {
  padding: 1px;
  position: fixed;
  background: rgba(255, 73, 73, 0.1);
  border: 2px dashed rgba(255, 73, 73, 0.7);
}

.hide {
  display: none;
}
</style>


================================================
FILE: src/modules/overlay/constants.js
================================================
export const overlaySelectors = {
  OVERLAY_ID: 'headless-recorder-overlay',
  SELECTOR_ID: 'headless-recorder-selector',
  CURRENT_SELECTOR_CLASS: 'headless-recorder-selected-element',
  CURSOR_CAMERA_CLASS: 'headless-recorder-camera-cursor',
  FLASH_CLASS: 'headless-recorder-flash',
}

export const overlayActions = {
  COPY: 'COPY',
  STOP: 'STOP',
  CLOSE: 'CLOSE',
  PAUSE: 'PAUSE',
  UNPAUSE: 'UNPAUSE',
  RESTART: 'RESTART',
  FULL_SCREENSHOT: 'FULL_SCREENSHOT',
  CLIPPED_SCREENSHOT: 'CLIPPED_SCREENSHOT',
  ABORT_SCREENSHOT: 'ABORT_SCREENSHOT',

  TOGGLE_SCREENSHOT_MODE: 'TOGGLE_SCREENSHOT_MODE',
  TOGGLE_SCREENSHOT_CLIPPED_MODE: 'TOGGLE_SCREENSHOT_CLIPPED_MODE',
  CLOSE_SCREENSHOT_MODE: 'CLOSE_SCREENSHOT_MODE',
  TOGGLE_OVERLAY: 'TOGGLE_OVERLAY',
}


================================================
FILE: src/modules/overlay/index.js
================================================
import { createApp } from 'vue'

import getSelector from '@/services/selector'
import SelectorApp from '@/modules/overlay/Selector.vue'
import OverlayApp from '@/modules/overlay/Overlay.vue'
import { overlaySelectors } from '@/modules/overlay/constants'

export default class Overlay {
  constructor({ store }) {
    this.overlayApp = null
    this.selectorApp = null

    this.overlayContainer = null
    this.selectorContainer = null

    this.mouseOverEvent = null
    this.scrollEvent = null
    this.isScrolling = false

    this.store = store
  }

  mount({ clear = false, pause = false } = {}) {
    if (this.overlayContainer) {
      return
    }

    this.overlayContainer = document.createElement('div')
    this.overlayContainer.id = overlaySelectors.OVERLAY_ID
    document.body.appendChild(this.overlayContainer)

    this.selectorContainer = document.createElement('div')
    this.selectorContainer.id = overlaySelectors.SELECTOR_ID
    document.body.appendChild(this.selectorContainer)

    if (clear) {
      this.store.commit('clear')
    }
    if (pause) {
      this.store.commit('pause')
    }

    this.selectorApp = createApp(SelectorApp)
      .use(this.store)
      .mount('#' + overlaySelectors.SELECTOR_ID)

    this.overlayApp = createApp(OverlayApp)
      .use(this.store)
      .mount('#' + overlaySelectors.OVERLAY_ID)

    this.mouseOverEvent = e => {
      const selector = getSelector(e, { dataAttribute: this.store.state.dataAttribute })
      this.overlayApp.currentSelector = selector.includes('#' + overlaySelectors.OVERLAY_ID)
        ? ''
        : selector

      if (
        this.overlayApp.currentSelector &&
        (!this.store.state.screenshotMode || this.store.state.screenshotClippedMode)
      ) {
        this.selectorApp.move(e, [overlaySelectors.OVERLAY_ID])
      }
    }

    // Hide selector while the user is scrolling
    this.scrollEvent = () => {
      this.selectorApp.scrolling = true
      window.clearTimeout(this.isScrolling)
      this.isScrolling = setTimeout(() => (this.selectorApp.scrolling = false), 66)
    }

    window.document.addEventListener('mouseover', this.mouseOverEvent)
    window.addEventListener('scroll', this.scrollEvent, false)
  }

  unmount() {
    if (!this.overlayContainer) {
      return
    }

    document.body.removeChild(this.overlayContainer)
    document.body.removeChild(this.selectorContainer)

    this.overlayContainer = null
    this.overlayApp = null
    this.selectorContainer = null
    this.selectorApp = null

    window.document.removeEventListener('mouseover', this.mouseOverEvent)
    window.removeEventListener('scroll', this.scrollEvent, false)
  }
}


================================================
FILE: src/modules/recorder/index.js
================================================
import getSelector from '@/services/selector'
import { recordingControls } from '@/services/constants'
import { overlaySelectors } from '@/modules/overlay/constants'
import { eventsToRecord } from '@/modules/code-generator/constants'

export default class Recorder {
  constructor({ store }) {
    // this._boundedMessageListener = null
    this._eventLog = []
    this._previousEvent = null

    this._isTopFrame = window.location === window.parent.location
    this._isRecordingClicks = true

    this.store = store
  }

  init(cb) {
    const events = Object.values(eventsToRecord)

    if (!window.pptRecorderAddedControlListeners) {
      this._addAllListeners(events)
      cb && cb()
      window.pptRecorderAddedControlListeners = true
    }

    if (!window.document.pptRecorderAddedControlListeners && chrome.runtime?.onMessage) {
      window.document.pptRecorderAddedControlListeners = true
    }

    if (this._isTopFrame) {
      this._sendMessage({ control: recordingControls.EVENT_RECORDER_STARTED })
      this._sendMessage({ control: recordingControls.GET_CURRENT_URL, href: window.location.href })
      this._sendMessage({
        control: recordingControls.GET_VIEWPORT_SIZE,
        coordinates: { width: window.innerWidth, height: window.innerHeight },
      })
    }
  }

  _addAllListeners(events) {
    const boundedRecordEvent = this._recordEvent.bind(this)
    events.forEach(type => window.addEventListener(type, boundedRecordEvent, true))
  }

  _sendMessage(msg) {
    // filter messages based on enabled / disabled features
    if (msg.action === 'click' && !this._isRecordingClicks) {
      return
    }

    try {
      chrome.runtime && chrome?.runtime?.onMessage
        ? chrome.runtime.sendMessage(msg)
        : this._eventLog.push(msg)
    } catch (err) {
      console.debug('caught error', err)
    }
  }

  _recordEvent(e) {
    if (this._previousEvent && this._previousEvent.timeStamp === e.timeStamp) {
      return
    }
    this._previousEvent = e

    // we explicitly catch any errors and swallow them, as none node-type events are also ingested.
    // for these events we cannot generate selectors, which is OK
    try {
      const selector = getSelector(e, { dataAttribute: this.store.state.dataAttribute })

      if (selector.includes('#' + overlaySelectors.OVERLAY_ID)) {
        return
      }

      this.store.commit('showRecorded')

      this._sendMessage({
        selector,
        value: e.target.value,
        tagName: e.target.tagName,
        action: e.type,
        keyCode: e.keyCode ? e.keyCode : null,
        href: e.target.href ? e.target.href : null,
        coordinates: Recorder._getCoordinates(e),
      })
    } catch (err) {
      console.error(err)
    }
  }

  _getEventLog() {
    return this._eventLog
  }

  _clearEventLog() {
    this._eventLog = []
  }

  disableClickRecording() {
    this._isRecordingClicks = false
  }

  enableClickRecording() {
    this._isRecordingClicks = true
  }

  static _getCoordinates(evt) {
    const eventsWithCoordinates = {
      mouseup: true,
      mousedown: true,
      mousemove: true,
      mouseover: true,
    }

    return eventsWithCoordinates[evt.type] ? { x: evt.clientX, y: evt.clientY } : null
  }
}


================================================
FILE: src/modules/shooter/index.js
================================================
import EventEmitter from 'events'
import getSelector from '@/services/selector'
import { overlayActions, overlaySelectors } from '@/modules/overlay/constants'

const BORDER_THICKNESS = 2
class Shooter extends EventEmitter {
  constructor({ isClipped = false, store } = {}) {
    super()

    this.store = store
    this.isClipped = isClipped

    this._overlay = null
    this._selector = null
    this._element = null
    this._dimensions = {}
    this.currentSelctor = ''

    this._boundeMouseMove = this.mousemove.bind(this)
    this._boundeMouseOver = this.mouseover.bind(this)
    this._boundeMouseUp = this.mouseup.bind(this)
    this._boundedKeyUp = this.keyup.bind(this)
  }

  mouseover(e) {
    this.currentSelctor = getSelector(e, { dataAttribute: this.store.state.dataAttribute }).replace(
      '.' + overlaySelectors.CURSOR_CAMERA_CLASS,
      'body'
    )
  }

  startScreenshotMode() {
    if (!this._overlay) {
      this._overlay = window.document.createElement('div')
      this._overlay.id = 'headless-recorder-shooter'
      this._overlay.style.position = 'fixed'
      this._overlay.style.top = '0px'
      this._overlay.style.left = '0px'
      this._overlay.style.width = '100%'
      this._overlay.style.height = '100%'
      this._overlay.style.pointerEvents = 'none'
      this._overlay.style.zIndex = 2147483646

      if (this.isClipped) {
        this._selector = window.document.createElement('div')
        this._selector.id = 'headless-recorder-shooter-outline'
        this._selector.style.position = 'fixed'
        this._overlay.appendChild(this._selector)
      } else {
        this._overlay.style.border = `${BORDER_THICKNESS}px dashed rgba(255, 73, 73, 0.7)`
        this._overlay.style.background = 'rgba(255, 73, 73, 0.1)'
      }
    }

    if (!this._overlay.parentNode) {
      window.document.body.appendChild(this._overlay)

      window.document.body.addEventListener('mousemove', this._boundeMouseMove, false)
      window.document.body.addEventListener('click', this._boundeMouseUp, false)
      window.document.body.addEventListener('keyup', this._boundedKeyUp, false)
      window.document.addEventListener('mouseover', this._boundeMouseOver, false)
    }
  }

  stopScreenshotMode() {
    if (this._overlay) {
      window.document.body.removeChild(this._overlay)
    }
    this._overlay = this._selector = this._element = null
    this._dimensions = {}
  }

  showScreenshotEffect() {
    window.document.body.classList.add(overlaySelectors.FLASH_CLASS)
    window.document.body.classList.remove(overlaySelectors.CURSOR_CAMERA_CLASS)
    setTimeout(() => window.document.body.classList.remove(overlaySelectors.FLASH_CLASS), 1000)
  }

  addCameraIcon() {
    window.document.body.classList.add(overlaySelectors.CURSOR_CAMERA_CLASS)
  }

  removeCameraIcon() {
    window.document.body.classList.remove(overlaySelectors.CURSOR_CAMERA_CLASS)
  }

  mousemove(e) {
    if (this._element !== e.target) {
      this._element = e.target

      this._dimensions.top = -window.scrollY
      this._dimensions.left = -window.scrollX

      let elem = e.target

      while (elem && elem !== window.document.body) {
        this._dimensions.top += elem.offsetTop
        this._dimensions.left += elem.offsetLeft
        elem = elem.offsetParent
      }
      this._dimensions.width = this._element.offsetWidth
      this._dimensions.height = this._element.offsetHeight

      if (this._selector) {
        this._selector.style.top = this._dimensions.top - BORDER_THICKNESS + 'px'
        this._selector.style.left = this._dimensions.left - BORDER_THICKNESS + 'px'
        this._selector.style.width = this._dimensions.width + 'px'
        this._selector.style.height = this._dimensions.height + 'px'
      }
    }
  }

  mouseup(e) {
    setTimeout(() => {
      this.cleanup()
      const payload = { raw: e }

      if (this.isClipped) {
        payload.selector = this.currentSelctor
      }

      this.emit('click', payload)
    }, 100)
  }

  keyup(e) {
    if (e.code !== 'Escape') {
      return
    }

    this.cleanup()
    this.removeCameraIcon()
    chrome.runtime.sendMessage({ control: overlayActions.ABORT_SCREENSHOT })
  }

  cleanup() {
    window.document.body.removeEventListener('mousemove', this._boundeMouseMove, false)
    window.document.body.removeEventListener('click', this._boundeMouseUp, false)
    window.document.body.removeEventListener('keyup', this._boundedKeyUp, false)
    window.document.removeEventListener('mouseover', this._boundeMouseOver, false)

    window.document.body.removeChild(this._overlay)
    this._overlay = null
  }
}

export default Shooter


================================================
FILE: src/options/OptionsApp.vue
================================================
<template>
  <main class="bg-gray-lightest flex py-9 w-full h-screen overflow-auto dark:bg-black">
    <div class="flex flex-col w-1/4 pt-12 pr-6">
      <a href="https://www.checklyhq.com/docs/headless-recorder/" target="_blank">Docs</a>
      <a href="https://github.com/checkly/headless-recorder" target="_blank">GitHub</a>
      <a href="https://github.com/checkly/headless-recorder/blob/main/CHANGELOG.md"
        >Release notes</a
      >
      <a
        href="https://chrome.google.com/webstore/detail/headless-recorder/djeegiggegleadkkbgopoonhjimgehda"
        target="_blank"
        >Chrome Web Store</a
      >
    </div>
    <div class="flex flex-col w-1/2">
      <header class="flex flex-row justify-between items-center mb-3.5">
        <div class="flex items-baseline">
          <h1 class="text-blue text-2xl font-bold mr-1">
            Headless Recorder
          </h1>
          <span class="text-gray-dark dark:text-gray-light text-sm">v{{ version }}</span>
        </div>
        <span
          role="alert"
          class="text-gray-darkest dark:text-white text-base font-semibold"
          v-show="saving"
          >Saving...</span
        >
      </header>

      <section>
        <h2>Recorder</h2>
        <label for="custom-data-attribute">Custom data attribute</label>
        <div class="mb-6">
          <input
            id="custom-data-attribute"
            class="w-full placeholder-gray-darkish bg-gray-lighter h-7 rounded px-2 mb-2 text-sm"
            type="text"
            v-model.trim="options.code.dataAttribute"
            @change="save"
            placeholder="your custom data-* attribute"
          />
          <p>
            Define an attribute that we'll attempt to use when selecting the elements, i.e
            "data-custom". This is handy when React or Vue based apps generate random class names.
          </p>
          <p>
            <span role="img" aria-label="siren">🚨</span>
            <span class="ml-1 font-bold text-black-shady dark:text-white"
              >When <span class="italic">"custom data attribute"</span>&nbsp; is set, it will take
              precedence from over any other selector (even ID)
            </span>
          </p>
        </div>
        <div>
          <label>Set key code</label>
          <div class="mb-2">
            <Button @click="listenForKeyCodePress" class="font-semibold text-white text-sm">
              {{ recordingKeyCodePress ? 'Capturing...' : 'Record Key Stroke' }}
            </Button>
            <span class="text-gray-dark dark:text-gray-light text-sm ml-3">
              {{ options.code.keyCode }}
            </span>
          </div>
          <p>
            What key will be used for capturing input changes. The value here is the key code. This
            will not handle multiple keys.
          </p>
        </div>
      </section>

      <section>
        <h2>Generator</h2>
        <Toggle v-model="options.code.wrapAsync">
          Wrap code in async function
        </Toggle>
        <Toggle v-model="options.code.headless">
          Set <code>headless</code> in playwright/puppeteer launch options
        </Toggle>
        <Toggle v-model="options.code.waitForNavigation">
          Add <code>waitForNavigation</code> lines on navigation
        </Toggle>
        <Toggle v-model="options.code.waitForSelectorOnClick">
          Add <code>waitForSelector</code> lines before every
          <code>page.click()</code>
        </Toggle>
        <Toggle v-model="options.code.blankLinesBetweenBlocks">
          Add blank lines between code blocks
        </Toggle>
        <Toggle v-model="options.code.showPlaywrightFirst">
          Show Playwright tab first
        </Toggle>
      </section>

      <section>
        <h2 class="">Extension</h2>
        <Toggle v-model="options.extension.darkMode">
          Use Dark Mode {{ options.extension.darkMode }}
        </Toggle>
        <Toggle v-model="options.extension.telemetry">
          Allow recording of usage telemetry
        </Toggle>
        <p>
          We only record clicks for basic product development, no website content or input data.
          Data is never, ever shared with 3rd parties.
        </p>
      </section>
    </div>
  </main>
</template>

<script>
import { version } from '../../package.json'

import storage from '@/services/storage'
import { isDarkMode } from '@/services/constants'
import { defaults as code } from '@/modules/code-generator/base-generator'
import { merge } from 'lodash'

import Button from '@/components/Button'
import Toggle from '@/components/Toggle'

const defaultOptions = {
  code,
  extension: {
    telemetry: true,
    darkMode: isDarkMode(),
  },
}

export default {
  name: 'OptionsApp',
  components: { Toggle, Button },

  data() {
    return {
      version,
      loading: true,
      saving: false,
      options: defaultOptions,
      recordingKeyCodePress: false,
    }
  },

  watch: {
    options: {
      handler() {
        this.save()
      },
      deep: true,
    },

    'options.extension.darkMode': {
      handler(newVal) {
        document.body.classList[newVal ? 'add' : 'remove']('dark')
      },
      immediate: true,
    },
  },

  mounted() {
    this.load()
    chrome.storage.onChanged.addListener(({ options = null }) => {
      if (options && options.newValue.extension.darkMode !== this.options.extension.darkMode) {
        this.options.extension.darkMode = options.newValue.extension.darkMode
      }
    })
  },

  methods: {
    async save() {
      this.saving = true
      await storage.set({ options: this.options })

      setTimeout(() => (this.saving = false), 500)
    },

    async load() {
      const { options } = await storage.get('options')
      merge(defaultOptions, options)
      this.options = Object.assign({}, this.options, defaultOptions)

      this.loading = false
    },

    listenForKeyCodePress() {
      this.recordingKeyCodePress = true

      const keyDownFunction = e => {
        this.recordingKeyCodePress = false
        this.updateKeyCodeWithNumber(e)
        window.removeEventListener('keydown', keyDownFunction, false)
        e.preventDefault()
      }

      window.addEventListener('keydown', keyDownFunction, false)
    },

    updateKeyCodeWithNumber(evt) {
      this.options.code.keyCode = parseInt(evt.keyCode, 10)
      this.save()
    },
  },
}
</script>

<style scoped>
body {
  background: #f9fafc;
  height: 100vh;
}

body.dark {
  background: #161616;
}

code {
  @apply font-semibold;
}

a {
  @apply text-blue underline text-sm text-right;
}

h2 {
  @apply text-gray-darkish text-xl font-semibold mb-5 dark:text-gray-light;
}

label {
  color: #000;
  @apply font-semibold text-sm mb-2 block dark:text-gray-lightest;
}

section {
  @apply bg-white border-gray-light border border-solid rounded-md p-4 pb-10 mb-6 dark:bg-black-shady dark:border-gray-dark;
}

p {
  @apply text-gray-darkish text-xs mb-2 dark:text-white;
}
</style>


================================================
FILE: src/options/__tests__/App.spec.js
================================================
import { mount } from '@vue/test-utils'
import App from '../OptionsApp'

function createChromeLocalStorageMock(options) {
  let ops = options || {}
  return {
    options,
    storage: {
      local: {
        get: (key, cb) => {
          return cb(ops)
        },
        set: (options, cb) => {
          ops = options
          cb()
        },
      },
    },
  }
}

describe('App.vue', () => {
  beforeEach(() => {
    window.chrome = null
  })

  test('it has the correct pristine / empty state', () => {
    window.chrome = createChromeLocalStorageMock()
    const wrapper = mount(App)
    expect(wrapper.element).toMatchSnapshot()
  })

  test('it loads the default options', () => {
    window.chrome = createChromeLocalStorageMock()
    const wrapper = mount(App)
    expect(wrapper.vm.$data.options.code.wrapAsync).toBeTruthy()
  })

  test('it has the default key code for capturing inputs as 9 (Tab)', () => {
    window.chrome = createChromeLocalStorageMock()
    const wrapper = mount(App)
    expect(wrapper.vm.$data.options.code.keyCode).toBe(9)
  })

  test('clicking the button will listen for the next keydown and update the key code option', () => {
    const options = { code: { keyCode: 9 } }
    window.chrome = createChromeLocalStorageMock(options)
    const wrapper = mount(App)

    return wrapper.vm
      .$nextTick()
      .then(() => {
        wrapper.find('button').element.click()
        const event = new KeyboardEvent('keydown', { keyCode: 16 })
        window.dispatchEvent(event)
        return wrapper.vm.$nextTick()
      })
      .then(() => {
        expect(wrapper.vm.$data.options.code.keyCode).toBe(16)
      })
  })

  test("it stores and loads the user's edited options", () => {
    const options = { code: { wrapAsync: true } }
    window.chrome = createChromeLocalStorageMock(options)
    const wrapper = mount(App)

    return wrapper.vm
      .$nextTick()
      .then(() => {
        const checkBox = wrapper.find('#options-code-wrapAsync')
        checkBox.trigger('click')
        expect(wrapper.find('.saving-badge').text()).toEqual('Saving...')
        return wrapper.vm.$nextTick()
      })
      .then(() => {
        // we need to simulate a page reload
        wrapper.vm.load()
        return wrapper.vm.$nextTick()
      })
      .then(() => {
        const checkBox = wrapper.find('#options-code-wrapAsync')
        return expect(checkBox.element.checked).toBeFalsy()
      })
  })
})


================================================
FILE: src/options/__tests__/__snapshots__/App.spec.js.snap
================================================
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App.vue it has the correct pristine / empty state 1`] = `
<div
  class="options"
>
  <div
    class="container"
  >
    <div
      class="header"
    >
       Headless Recorder Options 
      <small
        class="saving-badge text-muted"
        style="display: none;"
      >
         Saving... 
      </small>
    </div>
    <!--v-if-->
    <div
      class="footer"
    >
       sponsored by 
      <a
        href="https://checklyhq.com"
        target="_blank"
      >
        <img
          alt=""
          src=""
        />
      </a>
    </div>
  </div>
</div>
`;


================================================
FILE: src/options/main.js
================================================
import { createApp } from 'vue'
import App from './OptionsApp.vue'

import '@/assets/tailwind.css'

createApp(App).mount('#app')


================================================
FILE: src/popup/PopupApp.vue
================================================
<template>
  <div class="bg-gray-lightest dark:bg-black flex flex-col overflow-hidden">
    <Header @options="openOptions" @help="goHelp" @dark="toggleDarkMode" />

    <Home v-if="!showResultsTab && !isRecording" @start="toggleRecord" />

    <Recording
      @stop="toggleRecord"
      @pause="togglePause"
      @restart="restart(true)"
      :is-recording="isRecording"
      :is-paused="isPaused"
      :dark-mode="options?.extension?.darkMode"
      v-show="!showResultsTab && isRecording"
    />

    <Results
      :puppeteer="code"
      :playwright="codeForPlaywright"
      :options="options"
      v-if="showResultsTab"
      v-on:update:tab="currentResultTab = $event"
    />

    <!-- TODO: Move this into its own component -->
    <div
      data-test-id="results-footer"
      class="flex py-2 px-3 justify-between bg-black-shady"
      v-show="showResultsTab"
    >
      <Button dark class="mr-2" @click="restart" v-show="code">
        <img src="/icons/dark/sync.svg" class="mr-1" alt="restart recording" />
        Restart
      </Button>
      <Button dark class="mr-2 w-34" @click="copyCode" v-show="code">
        <img
          v-show="!isCopying"
          src="/icons/dark/duplicate.svg"
          class="mr-1"
          alt="copy code to clipboard"
        />
        <span v-show="!isCopying">Copy to clipboard</span>
        <span v-show="isCopying">Copied!</span>
      </Button>
      <Button @click="run" v-show="code">
        <img src="/icons/light/zap.svg" class="mr-1" alt="thunder" />
        Run on Checkly
      </Button>
    </div>

    <Footer v-if="!isRecording && !showResultsTab" />
  </div>
</template>

<script>
import browser from '@/services/browser'
import storage from '@/services/storage'
import analytics from '@/services/analytics'
import { popupActions, isDarkMode } from '@/services/constants'

import CodeGenerator from '@/modules/code-generator'

import Home from '@/views/Home.vue'
import Results from '@/views/Results.vue'
import Recording from '@/views/Recording.vue'

import Button from '@/components/Button.vue'
import Footer from '@/components/Footer.vue'
import Header from '@/components/Header.vue'

let bus

const defaultOptions = {
  extension: {
    darkMode: isDarkMode(),
  },
  code: {},
}

export default {
  name: 'PopupApp',
  components: {
    Results,
    Recording,
    Home,
    Header,
    Footer,
    Button,
  },

  data() {
    return {
      isLoggedIn: false,
      showResultsTab: false,
      isRecording: false,
      isPaused: false,
      isCopying: false,
      currentResultTab: null,

      liveEvents: [],
      recording: [],

      code: '',
      codeForPlaywright: '',
      options: defaultOptions,
    }
  },

  watch: {
    'options.extension.darkMode': {
      handler(newVal) {
        document.body.classList[newVal ? 'add' : 'remove']('dark')
      },
      immediate: true,
    },
  },

  async mounted() {
    this.loadState()
    bus = browser.getBackgroundBus()
    this.isLoggedIn = await browser.getChecklyCookie()
  },

  methods: {
    toggleRecord(close = true) {
      if (this.isRecording) {
        this.stop()
      } else {
        close && window.close()
        this.start()
      }

      this.isRecording = !this.isRecording
      this.storeState()
    },

    togglePause(stop = false) {
      bus.postMessage({ action: this.isPaused ? popupActions.UN_PAUSE : popupActions.PAUSE, stop })
      this.isPaused = !this.isPaused

      this.storeState()
    },

    start() {
      analytics.trackEvent({ options: this.options, event: 'Start' })
      this.cleanUp()
      bus.postMessage({ action: popupActions.START })
    },

    async stop() {
      analytics.trackEvent({ options: this.options, event: 'Stop' })
      bus.postMessage({ action: popupActions.STOP })

      await this.generateCode()
      this.storeState()
    },

    restart(stop = false) {
      this.cleanUp()
      bus.postMessage({ action: popupActions.CLEAN_UP, value: stop })
    },

    cleanUp() {
      this.recording = this.liveEvents = []
      this.code = ''
      this.codeForPlaywright = ''
      this.showResultsTab = this.isRecording = this.isPaused = false
      this.storeState()
    },

    async generateCode() {
      const { recording, options = { code: {} } } = await storage.get(['recording', 'options'])
      const generator = new CodeGenerator(options.code)
      const { puppeteer, playwright } = generator.generate(recording)

      this.recording = recording
      this.code = puppeteer
      this.codeForPlaywright = playwright
      this.showResultsTab = true
    },

    openOptions() {
      analytics.trackEvent({ options: this.options, event: 'Options' })
      browser.openOptionsPage()
    },

    async loadState() {
      const {
        controls = {},
        code = '',
        options,
        codeForPlaywright = '',
        recording,
        clear,
        pause,
        restart,
      } = await storage.get([
        'controls',
        'code',
        'options',
        'codeForPlaywright',
        'recording',
        'clear',
        'pause',
        'restart',
      ])

      this.isRecording = controls.isRecording
      this.isPaused = controls.isPaused
      this.options = options || defaultOptions

      this.code = code
      this.codeForPlaywright = codeForPlaywright

      if (this.isRecording) {
        this.liveEvents = recording

        if (clear) {
          this.toggleRecord()
          storage.remove(['clear'])
        }

        if (pause) {
          this.togglePause(true)
          storage.remove(['pause'])
        }

        if (restart) {
          this.cleanUp()
          this.toggleRecord(false)
          storage.remove(['restart'])
        }
      } else if (this.code) {
        this.generateCode()
      }
    },

    storeState() {
      storage.set({
        code: this.code,
        codeForPlaywright: this.codeForPlaywright,
        controls: { isRecording: this.isRecording, isPaused: this.isPaused },
      })
    },

    async copyCode() {
      this.isCopying = true
      await browser.copyToClipboard(this.getCode())
      setTimeout(() => (this.isCopying = false), 500)
    },

    goHelp() {
      browser.openHelpPage()
    },

    toggleDarkMode() {
      this.options.extension.darkMode = !this.options.extension.darkMode
      storage.set({ options: this.options })
    },

    getCode() {
      return this.currentResultTab === 'puppeteer' ? this.code : this.codeForPlaywright
    },

    run() {
      browser.openChecklyRunner({
        code: this.getCode(),
        runner: this.currentResultTab,
        isLoggedIn: this.isLoggedIn,
      })
    },
  },
}
</script>

<style>
html {
  width: 386px;
  height: 535px;
}

button:focus-visible {
  outline: none;
  box-shadow: 0 0 2px 2px #51a7e8;
}

button:focus {
  outline: 0;
}
</style>


================================================
FILE: src/popup/__tests__/App.spec.js
================================================
import { shallowMount } from '@vue/test-utils'
import App from '../PopupApp'

const chrome = {
  storage: {
    local: {
      get: jest.fn(),
    },
  },
  extension: {
    connect: jest.fn(),
  },
}

describe('App.vue', () => {
  test('it has the correct pristine / empty state', () => {
    window.chrome = chrome
    const wrapper = shallowMount(App)
    expect(wrapper.element).toMatchSnapshot()
  })
})


================================================
FILE: src/popup/__tests__/__snapshots__/App.spec.js.snap
================================================
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App.vue it has the correct pristine / empty state 1`] = `
<div
  class="recorder"
  id="headless-recorder"
>
  <div
    class="header"
  >
    <a
      href="#"
    >
       Headless recorder 
      <span
        class="text-muted"
      >
        <small>
          1.0.0
        </small>
      </span>
    </a>
    <div
      class="left"
    >
      <div
        class="recording-badge"
        style="display: none;"
      >
        <span
          class="red-dot"
        />
         recording
      </div>
      <button
        class="header-button"
      >
        <img
          alt="help"
          src=""
        />
      </button>
      <button
        class="header-button"
      >
        <img
          alt="settings"
          src=""
        />
      </button>
    </div>
  </div>
  <div
    class="main"
  >
    <div
      class="tabs"
    >
      <recording-tab-stub
        code=""
        is-recording="false"
        live-events=""
      />
      <div
        class="recording-footer"
      >
        <button
          class="btn btn-sm btn-primary"
        >
          Record
        </button>
        <button
          class="btn btn-sm btn-primary btn-outline-primary"
          style="display: none;"
        >
          Pause
        </button>
        <a
          href="#"
          style="display: none;"
        >
          view code
        </a>
        <checkly-badge-stub />
      </div>
      <!--v-if-->
      <div
        class="results-footer"
        style="display: none;"
      >
        <button
          class="btn btn-sm btn-primary"
          style="display: none;"
        >
           Restart 
        </button>
        <a
          href="#"
          style="display: none;"
        >
          copy to clipboard
        </a>
      </div>
    </div>
    <help-tab-stub
      style="display: none;"
    />
  </div>
</div>
`;


================================================
FILE: src/popup/main.js
================================================
import { createApp } from 'vue'
import VueHighlightJS from 'vue3-highlightjs'

import '@/assets/code.css'
import '@/assets/tailwind.css'

import App from './PopupApp.vue'

createApp(App)
  .use(VueHighlightJS)
  .mount('#app')


================================================
FILE: src/services/__tests__/analytics.spec.js
================================================
import analytics from '../analytics'

Object.defineProperty(window, '_gaq', {
  writable: true,
  value: {
    push: jest.fn(),
  },
})

describe('trackPageView', () => {
  beforeEach(() => {
    window._gaq.push.mockClear()
  })

  it('has telemetry enabled', () => {
    const options = {
      extension: {
        telemetry: true,
      },
    }

    analytics.trackPageView(options)
    expect(window._gaq.push.mock.calls.length).toBe(1)
  })

  it('does not have telemetry enabled', () => {
    const options = {
      extension: {
        telemetry: false,
      },
    }

    analytics.trackPageView(options)
    expect(window._gaq.push.mock.calls.length).toBe(0)
  })
})

================================================
FILE: src/services/__tests__/badge.spec.js
================================================
import badge from '../badge'

global.chrome = {
  browserAction: {
    setIcon: jest.fn(),
    setBadgeText: jest.fn(text => (inputText.data = text)),
    setBadgeBackgroundColor: jest.fn(),
  },
}

const inputText = {
  data: '',
}

beforeEach(() => {
  chrome.browserAction.setIcon.mockClear()
  chrome.browserAction.setBadgeBackgroundColor.mockClear()
})

describe('start', () => {
  it('sets recording icon', () => {
    badge.start()
    expect(chrome.browserAction.setIcon.mock.calls.length).toBe(1)
  })
})

describe('pause', () => {
  it('sets pause icon', () => {
    badge.pause()
    expect(chrome.browserAction.setIcon.mock.calls.length).toBe(1)
  })
})

describe('setText', () => {
  it('sets selected text on the badge', () => {
    badge.setText('data')
    expect(inputText.data.text).toBe('data')
  })
})

describe('reset', () => {
  it('reset text to empty string', () => {
    badge.reset()
    badge.setText('')
    expect(inputText.data.text).toBe('')
  })
})

describe('wait', () => {
  it('changes text to wait', () => {
    badge.wait()
    badge.setText('wait')
    expect(chrome.browserAction.setBadgeBackgroundColor.mock.calls.length).toBe(1)
    expect(inputText.data.text).toBe('wait')
  })
})

describe('stop', () => {
  it('stops recording and sets result text', () => {
    badge.stop('data')
    expect(chrome.browserAction.setIcon.mock.calls.length).toBe(1)
    expect(chrome.browserAction.setBadgeBackgroundColor.mock.calls.length).toBe(1)
    expect(inputText.data.text).toBe('data')
  })
})


================================================
FILE: src/services/__tests__/browser.spec.js
================================================
import browser from '../browser'

const activeTab = { id: 1, active: true }

const copyText = {
  data: '',
}

const cookies = [
    {
      name: 'checkly'
    }
  ]


window.chrome = {
  tabs: {
    create: jest.fn(),
    query: jest.fn((options, cb) => (cb([activeTab]))),
    executeScript: jest.fn((options, cb) => (cb(options))),
    sendMessage: jest.fn(),
  },
  extension: {
    connect: jest.fn(),
  },
  runtime: {
    openOptionsPage: jest.fn()
  },
  cookies: {
    getAll: jest.fn((options, cb) => (cb(cookies)))
}}

global.navigator.permissions = {
  query: jest
    .fn()
    .mockImplementationOnce(() => Promise.resolve({ state: 'granted' })),
};

global.navigator.clipboard = {
  writeText: jest.fn(text => (copyText.data = text))
};

beforeEach(() => {
  window?.chrome?.tabs.create.mockClear()
  window?.chrome?.extension.connect.mockClear()
  window?.chrome?.runtime.openOptionsPage.mockClear()
  window?.chrome?.tabs.query.mockClear()
})

describe('getActiveTab', () => {
  it('returns the active tab', async () => {
    const activeTab = await browser.getActiveTab()
    expect(activeTab).toBe(activeTab)
    expect(window.chrome.tabs.query.mock.calls.length).toBe(1)
  })
})

describe('copyToClipboard', () => {
  it('copies text to clipboard', async () => {
    await browser.copyToClipboard('data')
    expect(window.navigator.clipboard.writeText.mock.calls.length).toBe(1)
  })
})

describe('injectContentScript', () => {
  it('executes content script', async () => {
    await browser.injectContentScript()
    expect(window.chrome.tabs.executeScript.mock.calls.length).toBe(1)
  })
})

describe('getChecklyCookie', () => {
  it('returns checkly cookie', async () => {
    await browser.getChecklyCookie()
    expect(window.chrome.cookies.getAll.mock.calls.length).toBe(1)
  })
})

describe('openChecklyRunner', () => {
  it('is not logged in', () => {
    browser.openChecklyRunner({code: 1, runner: 2, isLoggedIn: false})
    expect(window.chrome.tabs.create.mock.calls.length).toBe(1)
  })

  it('is logged in', () => {
    browser.openChecklyRunner({code: 1, runner: 2, isLoggedIn: true})
    expect(window.chrome.tabs.create.mock.calls.length).toBe(1)
  })
})

describe('getBackgroundBus', () => {
  it('gets backgorund bus', async () => {
    browser.getBackgroundBus()
    expect(window.chrome.extension.connect.mock.calls.length).toBe(1)
  })
})

describe('openOptionsPage', () => {
  it('calls function that opens options page', async () => {
    browser.openOptionsPage()
    expect(window.chrome.runtime.openOptionsPage.mock.calls.length).toBe(1)
  })
})

describe('openHelpPage', () => {
  it('calls function that creates new tab and opens help page', async () => {
    browser.openHelpPage()
    expect(window.chrome.tabs.create.mock.calls.length).toBe(1)
  })
})




================================================
FILE: src/services/__tests__/constants.spec.js
================================================
import { isDarkMode } from '../constants'

function setMatchMediaMock(matches) {
  Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: jest.fn(() => ({ matches })),
  })
}

describe('isDarkMode()', () => {
  beforeEach(() => {
    window?.matchMedia?.mockClear()
  })

  it('has darkMode enabled', () => {
    setMatchMediaMock(true)
    expect(isDarkMode()).toBe(true)
  })

  it('has darkMode disabled', () => {
    setMatchMediaMock(false)
    expect(isDarkMode()).toBe(false)
  })

  it('does not have matchMedia browser API', () => {
    window.matchMedia = null
    expect(isDarkMode()).toBe(false)
  })
})


================================================
FILE: src/services/__tests__/storage.spec.js
================================================
import storage from '../storage'

const store = {
  token: 'xxx',
  name: 'lionel',
}

beforeEach(() => {
  window.chrome = {
    storage: {
      local: {
        get: jest.fn((keys, cb) => {
          if (typeof keys === 'string') {
            return cb(store[keys])
          }
  
          const results = []
          if (Array.isArray(keys)) {
            keys.forEach(key => {
              results.push(store[key])
            })
    
            return cb(results)
          }
        }),
        remove: jest.fn((keys, cb) => {
            delete store[keys];
            return cb(store)
        }),
        set: jest.fn((props, cb) => {
          const newStore = { ...store, ...props }
          return cb(newStore)
      }),
      },
    },
  }

  window.chrome.storage.local.get.mockClear()
  window.chrome.storage.local.set.mockClear()
  window.chrome.storage.local.remove.mockClear()
})

describe('get', () => {
  it('return a single value', async () => {
    const token = await storage.get('token')
    expect(token).toBe(store.token)
    expect(window.chrome.storage.local.get.mock.calls.length).toBe(1)
  })

  it('return multiple values', async () => {
    const [token, name] = await storage.get(['token', 'name'])
    expect(token).toBe(store.token)
    expect(name).toBe(store.name)
    expect(window.chrome.storage.local.get.mock.calls.length).toBe(1)
  })

  it('return undefined when value not found', async () => {
    const nothing = await storage.get('nothing')
    expect(nothing).toBe(undefined)
  })

  it('does not have browser storage available', async () => {
    try {
      window.chrome.storage = null
      await storage.get('token');
    } catch (e) {
      expect(e).toEqual('Browser storage not available');
    }
  })
})

describe('remove', () => {
  it('removes a value', async () => {
    const store = await storage.remove('token')
    expect(store.token).toBe(undefined)
    expect(window.chrome.storage.local.remove.mock.calls.length).toBe(1)
  })

  it('does not have browser storage available', async () => {
    try {
      window.chrome.storage = null
      await storage.remove('token');
    } catch (e) {
      expect(e).toEqual('Browser storage not available');
    }
  })
})

describe('set', () => {
  it('set a new value or values', async () => {
    const newStore = await storage.set({age: 1, country: 2})
    expect(newStore.age).toBe(1)
    expect(newStore.country).toBe(2)
    expect(window.chrome.storage.local.set.mock.calls.length).toBe(1)
  })

  it('does not have browser storage available', async () => {
    try {
      window.chrome.storage = null
      await storage.set({age: 1, country: 2});
    } catch (e) {
      expect(e).toEqual('Browser storage not available');
    }
  })
})


================================================
FILE: src/services/analytics.js
================================================
export default {
  trackEvent({ event, options }) {
    if (options?.extension?.telemetry) {
      window?._gaq?.push(['_trackEvent', event, 'clicked'])
    }
  },

  trackPageView(options) {
    if (options?.extension?.telemetry) {
      window?._gaq?.push(['_trackPageview'])
    }
  },
}


================================================
FILE: src/services/badge.js
================================================
const DEFAULT_COLOR = '#45C8F1'
const RECORDING_COLOR = '#FF0000'

const DEFAULT_LOGO = './images/logo.png'
const RECORDING_LOGO = './images/logo-red.png'
const PAUSE_LOGO = './images/logo-yellow.png'

export default {
  stop(text) {
    chrome.browserAction.setIcon({ path: DEFAULT_LOGO })
    chrome.browserAction.setBadgeBackgroundColor({ color: DEFAULT_COLOR })
    this.setText(text)
  },

  reset() {
    this.setText('')
  },

  setText(text) {
    chrome.browserAction.setBadgeText({ text })
  },

  pause() {
    chrome.browserAction.setIcon({ path: PAUSE_LOGO })
  },

  start() {
    chrome.browserAction.setIcon({ path: RECORDING_LOGO })
  },

  wait() {
    chrome.browserAction.setBadgeBackgroundColor({ color: RECORDING_COLOR })
    this.setText('wait')
  },
}


================================================
FILE: src/services/browser.js
================================================
const CONTENT_SCRIPT_PATH = 'js/content-script.js'
const RUN_URL = 'https://app.checklyhq.com/checks/new/browser'
const DOCS_URL = 'https://www.checklyhq.com/docs/headless-recorder'
const SIGNUP_URL =
  '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'

export default {
  getActiveTab() {
    return new Promise(function(resolve) {
      chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => resolve(tab))
    })
  },

  async sendTabMessage({ action, value, clean } = {}) {
    const tab = await this.getActiveTab()
    chrome.tabs.sendMessage(tab.id, { action, value, clean })
  },

  injectContentScript() {
    return new Promise(function(resolve) {
      chrome.tabs.executeScript({ file: CONTENT_SCRIPT_PATH, allFrames: false }, res =>
        resolve(res)
      )
    })
  },

  copyToClipboard(text) {
    return navigator.permissions.query({ name: 'clipboard-write' })
      .then(result => {
      if (result.state !== 'granted' && result.state !== 'prompt') {
        return Promise.reject()
      }

      navigator.clipboard.writeText(text)
    })
  },

  getChecklyCookie() {
    return new Promise(function(resolve) {
      chrome.cookies.getAll({}, res =>
        resolve(res.find(cookie => cookie.name.startsWith('checkly_has_account')))
      )
    })
  },

  getBackgroundBus() {
    return chrome.extension.connect({ name: 'recordControls' })
  },

  openOptionsPage() {
    chrome.runtime.openOptionsPage?.()
  },

  openHelpPage() {
    chrome.tabs.create({ url: DOCS_URL })
  },

  openChecklyRunner({ code, runner, isLoggedIn }) {
    if (!isLoggedIn) {
      chrome.tabs.create({ url: SIGNUP_URL })
      return
    }

    const script = encodeURIComponent(btoa(code))
    const url = `${RUN_URL}?framework=${runner}&script=${script}`
    chrome.tabs.create({ url })
  },
}


================================================
FILE: src/services/constants.js
================================================
export const recordingControls = {
  EVENT_RECORDER_STARTED: 'EVENT_RECORDER_STARTED',
  GET_VIEWPORT_SIZE: 'GET_VIEWPORT_SIZE',
  GET_CURRENT_URL: 'GET_CURRENT_URL',
  GET_SCREENSHOT: 'GET_SCREENSHOT',
}

export const popupActions = {
  START: 'START',
  STOP: 'STOP',
  CLEAN_UP: 'CLEAN_UP',
  PAUSE: 'PAUSE',
  UN_PAUSE: 'UN_PAUSE',
}

export const isDarkMode = () =>
  window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : false


================================================
FILE: src/services/selector.js
================================================
import { finder } from '@medv/finder/finder.js'

export default function selector(e, { dataAttribute } = {}) {
  if (dataAttribute && e.target.getAttribute(dataAttribute)) {
    return `[${dataAttribute}="${e.target.getAttribute(dataAttribute)}"]`
  }

  if (e.target.id) {
    return `#${e.target.id}`
  }

  return finder(e.target, {
    seedMinLength: 5,
    optimizedMinLength: e.target.id ? 2 : 10,
    attr: name => name === dataAttribute,
  })
}


================================================
FILE: src/services/storage.js
================================================
export default {
  get(keys) {
    if (!chrome.storage || !chrome.storage.local) {
      return Promise.reject('Browser storage not available')
    }

    return new Promise(resolve => chrome.storage.local.get(keys, props => resolve(props)))
  },

  set(props) {
    if (!chrome.storage || !chrome.storage.local) {
      return Promise.reject('Browser storage not available')
    }

    return new Promise(resolve => chrome.storage.local.set(props, res => resolve(res)))
  },

  remove(keys) {
    if (!chrome.storage || !chrome.storage.local) {
      return Promise.reject('Browser storage not available')
    }

    return new Promise(resolve => chrome.storage.local.remove(keys, res => resolve(res)))
  },
}


================================================
FILE: src/store/index.js
================================================
import { createStore } from 'vuex'

import { overlayActions } from '@/modules/overlay/constants'

function clearState(state) {
  state.isClosed = false
  state.isPaused = false
  state.isStopped = false
  state.screenshotMode = false
  state.screenshotClippedMode = false

  state.recording = []
}

const store = createStore({
  state() {
    return {
      isCopying: false,
      isClosed: false,
      isPaused: false,
      isStopped: false,
      darkMode: false,
      screenshotMode: false,
      screenshotClippedMode: false,
      hasRecorded: false,

      dataAttribute: '',
      takeScreenshot: false,

      recording: [],
    }
  },

  mutations: {
    showRecorded(state) {
      state.hasRecorded = true
      setTimeout(() => (state.hasRecorded = false), 250)
    },

    showCopy(state) {
      state.isCopying = true
      setTimeout(() => (state.isCopying = false), 500)
    },

    takeScreenshot(state) {
      state.takeScreenshot = true
    },

    setDataAttribute(state, dataAttribute) {
      state.dataAttribute = dataAttribute
    },

    setDarkMode(state, darkMode) {
      state.darkMode = darkMode
    },

    setRecording(state, recording) {
      state.recording = recording
    },

    unpause(state) {
      state.isPaused = false
      chrome.runtime.sendMessage({ control: overlayActions.UNPAUSE })
    },

    pause(state) {
      state.isPaused = true
      chrome.runtime.sendMessage({ control: overlayActions.PAUSE })
    },

    close(state) {
      state.isClosed = true
      chrome.runtime.sendMessage({ control: overlayActions.CLOSE })
    },

    restart(state) {
      clearState(state)
      chrome.runtime.sendMessage({ control: overlayActions.RESTART })
    },

    clear(state) {
      clearState(state)
    },

    stop(state) {
      state.isStopped = true
      chrome.runtime.sendMessage({ control: overlayActions.STOP })
    },

    copy() {
      chrome.runtime.sendMessage({ control: overlayActions.COPY })
    },

    toggleScreenshotMode(state) {
      state.screenshotMode = !state.screenshotMode
    },

    startScreenshotMode(state, isClipped = false) {
      chrome.runtime.sendMessage({
        control: isClipped ? overlayActions.CLIPPED_SCREENSHOT : overlayActions.FULL_SCREENSHOT,
      })

      state.screenshotClippedMode = isClipped
      state.screenshotMode = true
    },

    stopScreenshotMode(state) {
      state.screenshotMode = false
    },
  },
})

// TODO: load state from local storage
chrome.storage.onChanged.addListener(({ options = null, recording = null }) => {
  if (options) {
    store.commit('setDarkMode', options.newValue.extension.darkMode)
  }

  if (recording) {
    store.commit('setRecording', recording.newValue)
  }
})

export default store


================================================
FILE: src/views/Home.vue
================================================
<template>
  <div class="flex flex-col items-center rounded-md pt-10 h-100">
    <h3 class="text-gray-darkest text-xl font-semibold mb-3 dark:text-gray-lightest">
      No recorded events yet
    </h3>
    <p class="text-gray-dark text-xs mb-5 text-center w-44 dark:text-gray-light">
      Record browser events by clicking record button
    </p>
    <RoundButton :small="false" @click="$emit('start')" class="p-10 mt-12">
      <div class="bg-red w-21 h-21 rounded-full"></div>
    </RoundButton>
  </div>
</template>

<script>
import RoundButton from '@/components/RoundButton'

export default {
  components: { RoundButton },
}
</script>


================================================
FILE: src/views/Recording.vue
================================================
<template>
  <section class="flex flex-col items-center rounded-md pt-8 h-full">
    <RecordingLabel class="w-1/3" :is-paused="isPaused" :v-show="isRecording" />
    <p class="text-gray-dark text-sm text-center w-72 dark:text-gray-light">
      Headless recorder currently recording your browser events.
    </p>
    <RoundButton big @click="$emit('stop')" class="p-12 mt-10">
      <div class="bg-gray-darkest rounded h-16 w-16 dark:bg-white"></div>
    </RoundButton>

    <div class="flex mt-13 mb-8 items-center justify-center">
      <div class="flex flex-col items-center justify-center mr-10">
        <RoundButton
          medium
          @click="$emit('pause')"
          class="flex flex-col items-center justify-center"
        >
          <img
            :src="`/icons/${darkMode ? 'dark' : 'light'}/play.svg`"
            v-show="isPaused"
            class="w-10 h-10"
            alt="resume recording"
          />
          <img
            :src="`/icons/${darkMode ? 'dark' : 'light'}/pause.svg`"
            v-show="!isPaused"
            class="w-10 h-10"
            alt="pause recording"
          />
        </RoundButton>
        <span class="mt-2 text-sm font-semibold text-gray-new">{{
          isPaused ? 'RESUME' : 'PAUSE'
        }}</span>
      </div>
      <div class="flex flex-col items-center justify-center">
        <RoundButton
          medium
          @click="$emit('restart')"
          class="flex flex-col items-center justify-center"
        >
          <img
            :src="`/icons/${darkMode ? 'dark' : 'light'}/sync.svg`"
            class="w-10 h-10"
            alt="restart recording"
          />
        </RoundButton>
        <span class="mt-2 text-sm font-semibold text-gray-new">RESTART</span>
      </div>
    </div>
  </section>
</template>

<script>
import RoundButton from '@/components/RoundButton'
import RecordingLabel from '@/components/RecordingLabel'

export default {
  components: { RoundButton, RecordingLabel },

  props: {
    darkMode: { type: Boolean, default: false },
    isRecording: { type: Boolean, default: false },
    isPaused: { type: Boolean, default: false },
  },
}
</script>


================================================
FILE: src/views/Results.vue
================================================
<template>
  <div
    data-test-id="results-tab"
    class="flex flex-col bg-blue-light overflow-hidden mt-4 h-100 dark:bg-black"
  >
    <div class="flex flex-row">
      <button
        v-for="tab in tabs"
        :key="tab"
        class="w-1/2 p-2 font-semibold text-xs capitalize rounded-t"
        :class="
          activeTab === tab
            ? 'bg-black text-gray-lightest dark:bg-black-shady'
            : 'text-gray-dark dark:text-gray'
        "
        @click.prevent="changeTab(tab)"
      >
        {{ tab }}
      </button>
    </div>

    <div class="sc p-2 bg-black dark:bg-black-shady">
      <pre
        v-if="code"
        v-highlightjs="code"
        class="overflow-auto bg-black dark:bg-black-shady h-100"
      >
      <code ref="code" class="javascript bg-black dark:bg-black-shady px-2 break-word whitespace-pre-wrap overflow-x-hidden"></code>
      </pre>
      <pre v-else>
        <code>No code yet...</code>
      </pre>
    </div>
  </div>
</template>
<script>
import { headlessTypes } from '@/modules/code-generator/constants'

export default {
  name: 'ResultsTab',

  props: {
    puppeteer: {
      type: String,
      default: '',
    },
    playwright: {
      type: String,
      default: '',
    },
    options: {
      type: Object,
      default: () => ({}),
    },
  },

  data() {
    return {
      activeTab: headlessTypes.PLAYWRIGHT,
      tabs: [headlessTypes.PLAYWRIGHT, headlessTypes.PUPPETEER],
    }
  },

  computed: {
    code() {
      return this.activeTab === headlessTypes.PUPPETEER ? this.puppeteer : this.playwright
    },
  },

  mounted() {
    if (!this.options?.code?.showPlaywrightFirst) {
      this.activeTab = headlessTypes.PUPPETEER
      this.tabs = this.tabs.reverse()
    }

    this.$emit('update:tab', this.activeTab)
  },

  methods: {
    changeTab(tab) {
      this.activeTab = tab
      this.$emit('update:tab', tab)
    },
  },
}
</script>

<style scoped>
pre::-webkit-scrollbar {
  height: 8px;
  width: 8px;
  margin-right: 10px;
  padding: 10px;
  background: transparent;
}

pre::-webkit-scrollbar-thumb {
  margin-right: 10px;
  padding: 10px;
  background: #e0e6ed;
  border-radius: 0.5rem;
}

pre::-webkit-scrollbar-corner {
  background: yellow;
}
</style>


================================================
FILE: tailwind.config.js
================================================
const colors = require('tailwindcss/colors')

module.exports = {
  purge: { content: ['./public/**/*.html', './src/**/*.vue'] },
  presets: [],
  darkMode: 'class', // or 'media' or 'class'
  theme: {
    screens: {
      sm: '640px',
      md: '768px',
      lg: '1024px',
      xl: '1280px',
      '2xl': '1536px',
    },
    colors: {
      transparent: 'transparent',
      current: 'currentColor',

      gray: {
        hover: '#474747',
        lightest: '#F9FAFC',
        lighter: '#EFF2F7',
        light: '#E0E6ED',
        DEFAULT: '#8492A6',
        dark: '#3C4858',
        darkish: '#677281',
        darkest: '#1F2D3D',
        new: '#697280',
      },

      blue: {
        lightest: '#EEF8FF',
        light: '#F0F8FF',
        DEFAULT: '#45C8F1',
        dark: '#18c2f8',
      },

      red: {
        DEFAULT: '#FF4949',
      },

      pink: {
        DEFAULT: '#FF659D',
      },

      black: {
        light: '#2E2E2E',
        dark: '#000',
        DEFAULT: '#161616',
        shady: '#1F1F1F',
      },

      yellow: {
        DEFAULT: '#ffc82c',
      },

      white: colors.white,
      green: colors.emerald,
      indigo: colors.indigo,
      purple: colors.violet,
    },
    spacing: {
      px: '1px',
      0: '0px',
      0.5: '0.125rem',
      1: '0.25rem',
      1.5: '0.375rem',
      2: '0.5rem',
      2.5: '0.625rem',
      3: '0.75rem',
      3.5: '0.875rem',
      4: '1rem',
      5: '1.25rem',
      6: '1.5rem',
      7: '1.75rem',
      8: '2rem',
      9: '2.25rem',
      10: '2.5rem',
      11: '2.75rem',
      12: '3rem',
      13: '3.188rem',
      14: '3.5rem',
      16: '4rem',
      20: '5rem',
      21: '5.375rem',
      24: '6rem',
      28: '7rem',
      32: '8rem',
      34: '8.6rem',
      36: '9rem',
      40: '10rem',
      44: '11rem',
      48: '12rem',
      52: '13rem',
      56: '14rem',
      60: '15rem',
      64: '16rem',
      72: '18rem',
      80: '20rem',
      96: '24rem',
    },
    animation: {
      none: 'none',
      spin: 'spin 1s linear infinite',
      ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
      pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
      bounce: 'bounce 1s infinite',
    },
    backdropBlur: theme => theme('blur'),
    backdropBrightness: theme => theme('brightness'),
    backdropContrast: theme => theme('contrast'),
    backdropGrayscale: theme => theme('grayscale'),
    backdropHueRotate: theme => theme('hueRotate'),
    backdropInvert: theme => theme('invert'),
    backdropOpacity: theme => theme('opacity'),
    backdropSaturate: theme => theme('saturate'),
    backdropSepia: theme => theme('sepia'),
    backgroundColor: theme => theme('colors'),
    backgroundImage: {
      none: 'none',
      'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))',
      'gradient-to-tr': 'linear-gradient(to top right, var(--tw-gradient-stops))',
      'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))',
      'gradient-to-br': 'linear-gradient(to bottom right, var(--tw-gradient-stops))',
      'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))',
      'gradient-to-bl': 'linear-gradient(to bottom left, var(--tw-gradient-stops))',
      'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))',
      'gradient-to-tl': 'linear-gradient(to top left, var(--tw-gradient-stops))',
    },
    backgroundOpacity: theme => theme('opacity'),
    backgroundPosition: {
      bottom: 'bottom',
      center: 'center',
      left: 'left',
      'left-bottom': 'left bottom',
      'left-top': 'left top',
      right: 'right',
      'right-bottom': 'right bottom',
      'right-top': 'right top',
      top: 'top',
    },
    backgroundSize: {
      auto: 'auto',
      cover: 'cover',
      contain: 'contain',
    },
    blur: {
      0: '0',
      sm: '4px',
      DEFAULT: '8px',
      md: '12px',
      lg: '16px',
      xl: '24px',
      '2xl': '40px',
      '3xl': '64px',
    },
    brightness: {
      0: '0',
      50: '.5',
      75: '.75',
      90: '.9',
      95: '.95',
      100: '1',
      105: '1.05',
      110: '1.1',
      125: '1.25',
      150: '1.5',
      200: '2',
    },
    borderColor: theme => ({
      ...theme('colors'),
      DEFAULT: theme('colors.gray.200', 'currentColor'),
    }),
    borderOpacity: theme => theme('opacity'),
    borderRadius: {
      none: '0px',
      sm: '0.188rem',
      DEFAULT: '0.25rem',
      md: '0.375rem',
      lg: '0.5rem',
      xl: '0.75rem',
      '2xl': '1rem',
      '3xl': '1.5rem',
      full: '9999px',
    },
    borderWidth: {
      DEFAULT: '1px',
      0: '0px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    boxShadow: {
      sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
      DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
      md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
      lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
      xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
      '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
      inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)',
      none: 'none',
    },
    contrast: {
      0: '0',
      50: '.5',
      75: '.75',
      100: '1',
      125: '1.25',
      150: '1.5',
      200: '2',
    },
    container: {},
    cursor: {
      auto: 'auto',
      default: 'default',
      pointer: 'pointer',
      wait: 'wait',
      text: 'text',
      move: 'move',
      help: 'help',
      'not-allowed': 'not-allowed',
    },
    divideColor: theme => theme('borderColor'),
    divideOpacity: theme => theme('borderOpacity'),
    divideWidth: theme => theme('borderWidth'),
    dropShadow: {
      sm: '0 1px 1px rgba(0,0,0,0.05)',
      DEFAULT: ['0 1px 2px rgba(0, 0, 0, 0.1)', '0 1px 1px rgba(0, 0, 0, 0.06)'],
      md: ['0 4px 3px rgba(0, 0, 0, 0.07)', '0 2px 2px rgba(0, 0, 0, 0.06)'],
      lg: ['0 10px 8px rgba(0, 0, 0, 0.04)', '0 4px 3px rgba(0, 0, 0, 0.1)'],
      xl: ['0 20px 13px rgba(0, 0, 0, 0.03)', '0 8px 5px rgba(0, 0, 0, 0.08)'],
      '2xl': '0 25px 25px rgba(0, 0, 0, 0.15)',
      none: '0 0 #0000',
    },
    fill: { current: 'currentColor' },
    grayscale: {
      0: '0',
      DEFAULT: '100%',
    },
    hueRotate: {
      '-180': '-180deg',
      '-90': '-90deg',
      '-60': '-60deg',
      '-30': '-30deg',
      '-15': '-15deg',
      0: '0deg',
      15: '15deg',
      30: '30deg',
      60: '60deg',
      90: '90deg',
      180: '180deg',
    },
    invert: {
      0: '0',
      DEFAULT: '100%',
    },
    flex: {
      1: '1 1 0%',
      auto: '1 1 auto',
      initial: '0 1 auto',
      none: 'none',
    },
    flexGrow: {
      0: '0',
      DEFAULT: '1',
    },
    flexShrink: {
      0: '0',
      DEFAULT: '1',
    },
    fontFamily: {
      sans: [
        'Inter',
        'ui-sans-serif',
        'system-ui',
        '-apple-system',
        'BlinkMacSystemFont',
        '"Segoe UI"',
        'Roboto',
        '"Helvetica Neue"',
        'Arial',
        '"Noto Sans"',
        'sans-serif',
        '"Apple Color Emoji"',
        '"Segoe UI Emoji"',
        '"Segoe UI Symbol"',
        '"Noto Color Emoji"',
      ],
      serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'],
      mono: [
        'ui-monospace',
        'SFMono-Regular',
        'Menlo',
        'Monaco',
        'Consolas',
        '"Liberation Mono"',
        '"Courier New"',
        'monospace',
      ],
    },
    fontSize: {
      xs: ['0.75rem', { lineHeight: '1rem' }],
      sm: ['0.875rem', { lineHeight: '1.25rem' }],
      base: ['1rem', { lineHeight: '1.5rem' }],
      lg: ['1.125rem', { lineHeight: '1.75rem' }],
      xl: ['1.25rem', { lineHeight: '1.75rem' }],
      '2xl': ['1.5rem', { lineHeight: '2rem' }],
      '3xl': ['1.875rem', { lineHeight: '2.25rem' }],
      '4xl': ['2.25rem', { lineHeight: '2.5rem' }],
      '5xl': ['3rem', { lineHeight: '1' }],
      '6xl': ['3.75rem', { lineHeight: '1' }],
      '7xl': ['4.5rem', { lineHeight: '1' }],
      '8xl': ['6rem', { lineHeight: '1' }],
      '9xl': ['8rem', { lineHeight: '1' }],
    },
    fontWeight: {
      thin: '100',
      extralight: '200',
      light: '300',
      normal: '400',
      medium: '500',
      semibold: '600',
      bold: '700',
      extrabold: '800',
      black: '900',
    },
    gap: theme => theme('spacing'),
    gradientColorStops: theme => theme('colors'),
    gridAutoColumns: {
      auto: 'auto',
      min: 'min-content',
      max: 'max-content',
      fr: 'minmax(0, 1fr)',
    },
    gridAutoRows: {
      auto: 'auto',
      min: 'min-content',
      max: 'max-content',
      fr: 'minmax(0, 1fr)',
    },
    gridColumn: {
      auto: 'auto',
      'span-1': 'span 1 / span 1',
      'span-2': 'span 2 / span 2',
      'span-3': 'span 3 / span 3',
      'span-4': 'span 4 / span 4',
      'span-5': 'span 5 / span 5',
      'span-6': 'span 6 / span 6',
      'span-7': 'span 7 / span 7',
      'span-8': 'span 8 / span 8',
      'span-9': 'span 9 / span 9',
      'span-10': 'span 10 / span 10',
      'span-11': 'span 11 / span 11',
      'span-12': 'span 12 / span 12',
      'span-full': '1 / -1',
    },
    gridColumnEnd: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
      8: '8',
      9: '9',
      10: '10',
      11: '11',
      12: '12',
      13: '13',
    },
    gridColumnStart: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
      8: '8',
      9: '9',
      10: '10',
      11: '11',
      12: '12',
      13: '13',
    },
    gridRow: {
      auto: 'auto',
      'span-1': 'span 1 / span 1',
      'span-2': 'span 2 / span 2',
      'span-3': 'span 3 / span 3',
      'span-4': 'span 4 / span 4',
      'span-5': 'span 5 / span 5',
      'span-6': 'span 6 / span 6',
      'span-full': '1 / -1',
    },
    gridRowStart: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
    },
    gridRowEnd: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
    },
    gridTemplateColumns: {
      none: 'none',
      1: 'repeat(1, minmax(0, 1fr))',
      2: 'repeat(2, minmax(0, 1fr))',
      3: 'repeat(3, minmax(0, 1fr))',
      4: 'repeat(4, minmax(0, 1fr))',
      5: 'repeat(5, minmax(0, 1fr))',
      6: 'repeat(6, minmax(0, 1fr))',
      7: 'repeat(7, minmax(0, 1fr))',
      8: 'repeat(8, minmax(0, 1fr))',
      9: 'repeat(9, minmax(0, 1fr))',
      10: 'repeat(10, minmax(0, 1fr))',
      11: 'repeat(11, minmax(0, 1fr))',
      12: 'repeat(12, minmax(0, 1fr))',
    },
    gridTemplateRows: {
      none: 'none',
      1: 'repeat(1, minmax(0, 1fr))',
      2: 'repeat(2, minmax(0, 1fr))',
      3: 'repeat(3, minmax(0, 1fr))',
      4: 'repeat(4, minmax(0, 1fr))',
      5: 'repeat(5, minmax(0, 1fr))',
      6: 'repeat(6, minmax(0, 1fr))',
    },
    height: theme => ({
      auto: 'auto',
      ...theme('spacing'),
      '100': '27rem',
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      '1/5': '20%',
      '2/5': '40%',
      '3/5': '60%',
      '4/5': '80%',
      '1/6': '16.666667%',
      '2/6': '33.333333%',
      '3/6': '50%',
      '4/6': '66.666667%',
      '5/6': '83.333333%',
      full: '100%',
      screen: '100vh',
    }),
    inset: (theme, { negative }) => ({
      auto: 'auto',
      ...theme('spacing'),
      ...negative(theme('spacing')),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      full: '100%',
      '-1/2': '-50%',
      '-1/3': '-33.333333%',
      '-2/3': '-66.666667%',
      '-1/4': '-25%',
      '-2/4': '-50%',
      '-3/4': '-75%',
      '-full': '-100%',
    }),
    keyframes: {
      spin: {
        to: {
          transform: 'rotate(360deg)',
        },
      },
      ping: {
        '75%, 100%': {
          transform: 'scale(2)',
          opacity: '0',
        },
      },
      pulse: {
        '50%': {
          opacity: '.5',
        },
      },
      bounce: {
        '0%, 100%': {
          transform: 'translateY(-25%)',
          animationTimingFunction: 'cubic-bezier(0.8,0,1,1)',
        },
        '50%': {
          transform: 'none',
          animationTimingFunction: 'cubic-bezier(0,0,0.2,1)',
        },
      },
    },
    letterSpacing: {
      tighter: '-0.05em',
      tight: '-0.025em',
      normal: '0em',
      wide: '0.025em',
      wider: '0.05em',
      widest: '0.1em',
    },
    lineHeight: {
      none: '1',
      tight: '1.25',
      snug: '1.375',
      normal: '1.5',
      relaxed: '1.625',
      loose: '2',
      3: '.75rem',
      4: '1rem',
      5: '1.25rem',
      6: '1.5rem',
      7: '1.75rem',
      8: '2rem',
      9: '2.25rem',
      10: '2.5rem',
    },
    listStyleType: {
      none: 'none',
      disc: 'disc',
      decimal: 'decimal',
    },
    margin: (theme, { negative }) => ({
      auto: 'auto',
      ...theme('spacing'),
      ...negative(theme('spacing')),
    }),
    maxHeight: theme => ({
      ...theme('spacing'),
      full: '100%',
      screen: '100vh',
    }),
    maxWidth: (theme, { breakpoints }) => ({
      none: 'none',
      0: '0rem',
      xs: '20rem',
      sm: '24rem',
      md: '28rem',
      lg: '32rem',
      xl: '36rem',
      '2xl': '42rem',
      '3xl': '48rem',
      '4xl': '56rem',
      '5xl': '64rem',
      '6xl': '72rem',
      '7xl': '80rem',
      full: '100%',
      min: 'min-content',
      max: 'max-content',
      prose: '65ch',
      ...breakpoints(theme('screens')),
    }),
    minHeight: {
      0: '0px',
      full: '100%',
      screen: '100vh',
    },
    minWidth: {
      0: '0px',
      full: '100%',
      min: 'min-content',
      max: 'max-content',
    },
    objectPosition: {
      bottom: 'bottom',
      center: 'center',
      left: 'left',
      'left-bottom': 'left bottom',
      'left-top': 'left top',
      right: 'right',
      'right-bottom': 'right bottom',
      'right-top': 'right top',
      top: 'top',
    },
    opacity: {
      0: '0',
      5: '0.05',
      10: '0.1',
      20: '0.2',
      25: '0.25',
      30: '0.3',
      40: '0.4',
      50: '0.5',
      60: '0.6',
      70: '0.7',
      75: '0.75',
      80: '0.8',
      90: '0.9',
      95: '0.95',
      100: '1',
    },
    order: {
      first: '-9999',
      last: '9999',
      none: '0',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
      8: '8',
      9: '9',
      10: '10',
      11: '11',
      12: '12',
    },
    outline: {
      none: ['2px solid transparent', '2px'],
      white: ['2px dotted white', '2px'],
      black: ['2px dotted black', '2px'],
    },
    padding: theme => theme('spacing'),
    placeholderColor: theme => theme('colors'),
    placeholderOpacity: theme => theme('opacity'),
    ringColor: theme => ({
      DEFAULT: theme('colors.blue.500', '#3b82f6'),
      ...theme('colors'),
    }),
    ringOffsetColor: theme => theme('colors'),
    ringOffsetWidth: {
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    ringOpacity: theme => ({
      DEFAULT: '0.5',
      ...theme('opacity'),
    }),
    ringWidth: {
      DEFAULT: '3px',
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    rotate: {
      '-180': '-180deg',
      '-90': '-90deg',
      '-45': '-45deg',
      '-12': '-12deg',
      '-6': '-6deg',
      '-3': '-3deg',
      '-2': '-2deg',
      '-1': '-1deg',
      0: '0deg',
      1: '1deg',
      2: '2deg',
      3: '3deg',
      6: '6deg',
      12: '12deg',
      45: '45deg',
      90: '90deg',
      180: '180deg',
    },
    saturate: {
      0: '0',
      50: '.5',
      100: '1',
      150: '1.5',
      200: '2',
    },
    scale: {
      0: '0',
      50: '.5',
      75: '.75',
      90: '.9',
      95: '.95',
      100: '1',
      105: '1.05',
      110: '1.1',
      125: '1.25',
      150: '1.5',
    },
    sepia: {
      0: '0',
      DEFAULT: '100%',
    },
    skew: {
      '-12': '-12deg',
      '-6': '-6deg',
      '-3': '-3deg',
      '-2': '-2deg',
      '-1': '-1deg',
      0: '0deg',
      1: '1deg',
      2: '2deg',
      3: '3deg',
      6: '6deg',
      12: '12deg',
    },
    space: (theme, { negative }) => ({
      ...theme('spacing'),
      ...negative(theme('spacing')),
    }),
    stroke: {
      current: 'currentColor',
    },
    strokeWidth: {
      0: '0',
      1: '1',
      2: '2',
    },
    textColor: theme => theme('colors'),
    textOpacity: theme => theme('opacity'),
    transformOrigin: {
      center: 'center',
      top: 'top',
      'top-right': 'top right',
      right: 'right',
      'bottom-right': 'bottom right',
      bottom: 'bottom',
      'bottom-left': 'bottom left',
      left: 'left',
      'top-left': 'top left',
    },
    transitionDelay: {
      75: '75ms',
      100: '100ms',
      150: '150ms',
      200: '200ms',
      300: '300ms',
      500: '500ms',
      700: '700ms',
      1000: '1000ms',
    },
    transitionDuration: {
      DEFAULT: '150ms',
      75: '75ms',
      100: '100ms',
      150: '150ms',
      200: '200ms',
      300: '300ms',
      500: '500ms',
      700: '700ms',
      1000: '1000ms',
    },
    transitionProperty: {
      none: 'none',
      all: 'all',
      DEFAULT:
        'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter',
      colors: 'background-color, border-color, color, fill, stroke',
      opacity: 'opacity',
      shadow: 'box-shadow',
      transform: 'transform',
    },
    transitionTimingFunction: {
      DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)',
      linear: 'linear',
      in: 'cubic-bezier(0.4, 0, 1, 1)',
      out: 'cubic-bezier(0, 0, 0.2, 1)',
      'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
    },
    translate: (theme, { negative }) => ({
      ...theme('spacing'),
      ...negative(theme('spacing')),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      full: '100%',
      '-1/2': '-50%',
      '-1/3': '-33.333333%',
      '-2/3': '-66.666667%',
      '-1/4': '-25%',
      '-2/4': '-50%',
      '-3/4': '-75%',
      '-full': '-100%',
    }),
    width: theme => ({
      auto: 'auto',
      ...theme('spacing'),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      '1/5': '20%',
      '2/5': '40%',
      '3/5': '60%',
      '4/5': '80%',
      '1/6': '16.666667%',
      '2/6': '33.333333%',
      '3/6': '50%',
      '4/6': '66.666667%',
      '5/6': '83.333333%',
      '1/12': '8.333333%',
      '2/12': '16.666667%',
      '3/12': '25%',
      '4/12': '33.333333%',
      '5/12': '41.666667%',
      '6/12': '50%',
      '7/12': '58.333333%',
      '8/12': '66.666667%',
      '9/12': '75%',
      '10/12': '83.333333%',
      '11/12': '91.666667%',
      full: '100%',
      screen: '100vw',
      min: 'min-content',
      max: 'max-content',
    }),
    zIndex: {
      auto: 'auto',
      0: '0',
      10: '10',
      20: '20',
      30: '30',
      40: '40',
      50: '50',
    },
  },
  variantOrder: [
    'first',
    'last',
    'odd',
    'even',
    'visited',
    'checked',
    'group-hover',
    'group-focus',
    'focus-within',
    'hover',
    'focus',
    'focus-visible',
    'active',
    'disabled',
  ],
  variants: {
    accessibility: ['responsive', 'focus-within', 'focus'],
    alignContent: ['responsive'],
    alignItems: ['responsive'],
    alignSelf: ['responsive'],
    animation: ['responsive'],
    appearance: ['responsive'],
    backdropBlur: ['responsive'],
    backdropBrightness: ['responsive'],
    backdropContrast: ['responsive'],
    backdropDropShadow: ['responsive'],
    backdropFilter: ['responsive'],
    backdropGrayscale: ['responsive'],
    backdropHueRotate: ['responsive'],
    backdropInvert: ['responsive'],
    backdropSaturate: ['responsive'],
    backdropSepia: ['responsive'],
    backgroundAttachment: ['responsive'],
    backgroundBlendMode: ['responsive'],
    backgroundClip: ['responsive'],
    backgroundColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'],
    backgroundImage: ['responsive'],
    backgroundOpacity: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'],
    backgroundPosition: ['responsive'],
    backgroundRepeat: ['responsive'],
    backgroundSize: ['responsive'],
    blur: ['responsive'],
    borderCollapse: ['responsive'],
    borderColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'],
    borderOpacity: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'],
    borderRadius: ['responsive'],
    borderStyle: ['responsive'],
    borderWidth: ['responsive'],
    boxDecorationBreak: ['responsive'],
    boxShadow: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'],
    boxSizing: ['responsive'],
    brightness: ['responsive'],
    clear: ['responsive'],
    container: ['responsive'],
    contrast: ['responsive'],
    cursor: ['responsive'],
    display: ['responsive'],
    divideColor: ['responsive', 'dark'],
    divideOpacity: ['responsive', 'dark'],
    divideStyle: ['responsive'],
    divideWidth: ['responsive'],
    dropShadow: ['responsive'],
    fill: ['responsive'],
    filter: ['responsive'],
    flex: ['responsive'],
    flexDirection: ['responsive'],
    flexGrow: ['responsive'],
    flexShrink: ['responsive'],
    flexWrap: ['responsive'],
    float: ['responsive'],
    fontFamily: ['responsive'],
    fontSize: ['responsive'],
    fontSmoothing: ['responsive'],
    fontStyle: ['responsive'],
    fontVariantNumeric: ['responsive'],
    fontWeight: ['responsive'],
    gap: ['responsive'],
    gradientColorStops: ['responsive', 'dark', 'hover', 'focus'],
    grayscale: ['responsive'],
    gridAutoColumns: ['responsive'],
    gridAutoFlow: ['responsive'],
    gridAutoRows: ['responsive'],
    gridColumn: ['responsive'],
    gridColumnEnd: ['responsive'],
    gridColumnStart: ['responsive'],
    gridRow: ['responsive'],
    gridRowEnd: ['responsive'],
    gridRowStart: ['responsive'],
    gridTemplateColumns: ['responsive'],
    gridTemplateRows: ['responsive'],
    height: ['responsive'],
    hueRotate: ['responsive'],
    inset: ['responsive'],
    invert: ['responsive'],
    isolation: ['responsive'],
    justifyContent: ['responsive'],
    justifyItems: ['responsive'],
    justifySelf: ['responsive'],
    letterSpacing: ['responsive'],
    lineHeight: ['responsive'],
    listStylePosition: ['responsive'],
    listStyleType: ['responsive'],
    margin: ['responsive'],
    maxHeight: ['responsive'],
    maxWidth: ['responsive'],
    minHeight: ['responsive'],
    minWidth: ['responsive'],
    mixBlendMode: ['responsive'],
    objectFit: ['responsive'],
    objectPosition: ['responsive'],
    opacity: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'],
    order: ['responsive'],
    outline: ['responsive', 'focus-within', 'focus'],
    overflow: ['responsive'],
    overscrollBehavior: ['responsive'],
    padding: ['responsive'],
    placeContent: ['responsive'],
    placeItems: ['responsive'],
    placeSelf: ['responsive'],
    placeholderColor: ['responsive', 'dark', 'focus'],
    placeholderOpacity: ['responsive', 'dark', 'focus'],
    pointerEvents: ['responsive'],
    position: ['responsive'],
    resize: ['responsive'],
    ringColor: ['responsive', 'dark', 'focus-within', 'focus'],
    ringOffsetColor: ['responsive', 'dark', 'focus-within', 'focus'],
    ringOffsetWidth: ['responsive', 'focus-within', 'focus'],
    ringOpacity: ['responsive', 'dark', 'focus-within', 'focus'],
    ringWidth: ['responsive', 'focus-within', 'focus'],
    rotate: ['responsive', 'hover', 'focus'],
    saturate: ['responsive'],
    scale: ['responsive', 'hover', 'focus'],
    sepia: ['responsive'],
    skew: ['responsive', 'hover', 'focus'],
    space: ['responsive'],
    stroke: ['responsive'],
    strokeWidth: ['responsive'],
    tableLayout: ['responsive'],
    textAlign: ['responsive'],
    textColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'],
    textDecoration: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'],
    textOpacity: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'],
    textOverflow: ['responsive'],
    textTransform: ['responsive'],
    transform: ['responsive'],
    transformOrigin: ['responsive'],
    transitionDelay: ['responsive'],
    transitionDuration: ['responsive'],
    transitionProperty: ['responsive'],
    transitionTimingFunction: ['responsive'],
    translate: ['responsive', 'hover', 'focus'],
    userSelect: ['responsive'],
    verticalAlign: ['responsive'],
    visibility: ['responsive'],
    whitespace: ['responsive'],
    width: ['responsive'],
    wordBreak: ['responsive'],
    zIndex: ['responsive', 'focus-within', 'focus'],
  },
  plugins: [],
}


================================================
FILE: vue.config.js
================================================
module.exports = {
  css: {
    extract: false,
  },

  pages: {
    popup: {
      template: 'public/browser-extension.html',
      entry: './src/popup/main.js',
      title: 'Popup',
    },
    options: {
      template: 'public/browser-extension.html',
      entry: './src/options/main.js',
      title: 'Options',
    },
  },
  pluginOptions: {
    browserExtension: {
      componentOptions: {
        background: {
          entry: 'src/background/index.js',
        },
        contentScripts: {
          entries: {
            'content-script': ['src/content-scripts/index.js'],
          },
        },
      },
    },
  },
}
Download .txt
gitextract_qm3mcne8/

├── .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
├── babel.config.js
├── dependabot.yml
├── jest.config.js
├── jest.setup.js
├── jsconfig.json
├── package.json
├── postcss.config.js
├── public/
│   ├── _locales/
│   │   └── en/
│   │       └── messages.json
│   └── browser-extension.html
├── src/
│   ├── __tests__/
│   │   ├── build.spec.js
│   │   └── helpers.js
│   ├── assets/
│   │   ├── animations.css
│   │   ├── code.css
│   │   └── 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
Download .txt
SYMBOL INDEX (138 symbols across 18 files)

FILE: src/background/index.js
  class Background (line 10) | class Background {
    method constructor (line 11) | constructor() {
    method init (line 31) | init() {
    method start (line 37) | async start() {
    method stop (line 78) | stop() {
    method pause (line 91) | pause() {
    method unPause (line 96) | unPause() {
    method cleanUp (line 101) | cleanUp() {
    method recordCurrentUrl (line 111) | recordCurrentUrl(href) {
    method recordCurrentViewportSize (line 123) | recordCurrentViewportSize(value) {
    method recordNavigation (line 134) | recordNavigation() {
    method recordScreenshot (line 142) | recordScreenshot(value) {
    method handleMessage (line 153) | handleMessage(msg, sender) {
    method handleOverlayMessage (line 173) | async handleOverlayMessage({ control }) {
    method handleRecordingMessage (line 235) | handleRecordingMessage({ control, href, value, coordinates }) {
    method handlePopupMessage (line 253) | handlePopupMessage(msg) {
    method handleNavigation (line 289) | async handleNavigation({ frameId }) {
    method toggleOverlay (line 299) | toggleOverlay({ open = false, clear = false, pause = false } = {}) {

FILE: src/content-scripts/controller.js
  class HeadlessController (line 9) | class HeadlessController {
    method constructor (line 10) | constructor({ overlay, recorder, store }) {
    method init (line 19) | async init() {
    method listenBackgroundMessages (line 31) | listenBackgroundMessages() {
    method handleBackgroundMessages (line 36) | async handleBackgroundMessages(msg) {
    method handleScreenshot (line 77) | handleScreenshot(isClipped) {
    method cancelScreenshot (line 96) | cancelScreenshot() {

FILE: src/modules/code-generator/base-generator.js
  class BaseGenerator (line 15) | class BaseGenerator {
    method constructor (line 16) | constructor(options) {
    method generate (line 27) | generate() {
    method _getHeader (line 31) | _getHeader() {
    method _getFooter (line 37) | _getFooter() {
    method _parseEvents (line 41) | _parseEvents(events) {
    method _setFrames (line 106) | _setFrames(frameId, frameUrl) {
    method _postProcess (line 117) | _postProcess() {
    method _handleKeyDown (line 128) | _handleKeyDown(selector, value) {
    method _handleClick (line 137) | _handleClick(selector) {
    method _handleChange (line 152) | _handleChange(selector, value) {
    method _handleGoto (line 159) | _handleGoto(href) {
    method _handleViewport (line 166) | _handleViewport() {
    method _handleScreenshot (line 170) | _handleScreenshot(value) {
    method _handleWaitForNavigation (line 187) | _handleWaitForNavigation() {
    method _postProcessSetFrames (line 198) | _postProcessSetFrames() {
    method _postProcessAddBlankLines (line 221) | _postProcessAddBlankLines() {
    method _escapeUserInput (line 231) | _escapeUserInput(value) {

FILE: src/modules/code-generator/block.js
  class Block (line 1) | class Block {
    method constructor (line 2) | constructor(frameId, line) {
    method addLineToTop (line 12) | addLineToTop(line) {
    method addLine (line 17) | addLine(line) {
    method getLines (line 22) | getLines() {

FILE: src/modules/code-generator/index.js
  class CodeGenerator (line 4) | class CodeGenerator {
    method constructor (line 5) | constructor(options = {}) {
    method generate (line 10) | generate(recording) {

FILE: src/modules/code-generator/playwright.js
  class PlaywrightCodeGenerator (line 18) | class PlaywrightCodeGenerator extends BaseGenerator {
    method constructor (line 19) | constructor(options) {
    method generate (line 27) | generate(events) {
    method _handleViewport (line 31) | _handleViewport(width, height) {
    method _handleChange (line 38) | _handleChange(selector, value) {

FILE: src/modules/code-generator/puppeteer.js
  class PuppeteerCodeGenerator (line 19) | class PuppeteerCodeGenerator extends BaseGenerator {
    method constructor (line 20) | constructor(options) {
    method generate (line 28) | generate(events) {
    method _handleViewport (line 32) | _handleViewport(width, height) {

FILE: src/modules/overlay/index.js
  class Overlay (line 8) | class Overlay {
    method constructor (line 9) | constructor({ store }) {
    method mount (line 23) | mount({ clear = false, pause = false } = {}) {
    method unmount (line 76) | unmount() {

FILE: src/modules/recorder/index.js
  class Recorder (line 6) | class Recorder {
    method constructor (line 7) | constructor({ store }) {
    method init (line 18) | init(cb) {
    method _addAllListeners (line 41) | _addAllListeners(events) {
    method _sendMessage (line 46) | _sendMessage(msg) {
    method _recordEvent (line 61) | _recordEvent(e) {
    method _getEventLog (line 92) | _getEventLog() {
    method _clearEventLog (line 96) | _clearEventLog() {
    method disableClickRecording (line 100) | disableClickRecording() {
    method enableClickRecording (line 104) | enableClickRecording() {
    method _getCoordinates (line 108) | static _getCoordinates(evt) {

FILE: src/modules/shooter/index.js
  constant BORDER_THICKNESS (line 5) | const BORDER_THICKNESS = 2
  class Shooter (line 6) | class Shooter extends EventEmitter {
    method constructor (line 7) | constructor({ isClipped = false, store } = {}) {
    method mouseover (line 25) | mouseover(e) {
    method startScreenshotMode (line 32) | startScreenshotMode() {
    method stopScreenshotMode (line 65) | stopScreenshotMode() {
    method showScreenshotEffect (line 73) | showScreenshotEffect() {
    method addCameraIcon (line 79) | addCameraIcon() {
    method removeCameraIcon (line 83) | removeCameraIcon() {
    method mousemove (line 87) | mousemove(e) {
    method mouseup (line 113) | mouseup(e) {
    method keyup (line 126) | keyup(e) {
    method cleanup (line 136) | cleanup() {

FILE: src/options/__tests__/App.spec.js
  function createChromeLocalStorageMock (line 4) | function createChromeLocalStorageMock(options) {

FILE: src/services/__tests__/constants.spec.js
  function setMatchMediaMock (line 3) | function setMatchMediaMock(matches) {

FILE: src/services/analytics.js
  method trackEvent (line 2) | trackEvent({ event, options }) {
  method trackPageView (line 8) | trackPageView(options) {

FILE: src/services/badge.js
  constant DEFAULT_COLOR (line 1) | const DEFAULT_COLOR = '#45C8F1'
  constant RECORDING_COLOR (line 2) | const RECORDING_COLOR = '#FF0000'
  constant DEFAULT_LOGO (line 4) | const DEFAULT_LOGO = './images/logo.png'
  constant RECORDING_LOGO (line 5) | const RECORDING_LOGO = './images/logo-red.png'
  constant PAUSE_LOGO (line 6) | const PAUSE_LOGO = './images/logo-yellow.png'
  method stop (line 9) | stop(text) {
  method reset (line 15) | reset() {
  method setText (line 19) | setText(text) {
  method pause (line 23) | pause() {
  method start (line 27) | start() {
  method wait (line 31) | wait() {

FILE: src/services/browser.js
  constant CONTENT_SCRIPT_PATH (line 1) | const CONTENT_SCRIPT_PATH = 'js/content-script.js'
  constant RUN_URL (line 2) | const RUN_URL = 'https://app.checklyhq.com/checks/new/browser'
  constant DOCS_URL (line 3) | const DOCS_URL = 'https://www.checklyhq.com/docs/headless-recorder'
  constant SIGNUP_URL (line 4) | const SIGNUP_URL =
  method getActiveTab (line 8) | getActiveTab() {
  method sendTabMessage (line 14) | async sendTabMessage({ action, value, clean } = {}) {
  method injectContentScript (line 19) | injectContentScript() {
  method copyToClipboard (line 27) | copyToClipboard(text) {
  method getChecklyCookie (line 38) | getChecklyCookie() {
  method getBackgroundBus (line 46) | getBackgroundBus() {
  method openOptionsPage (line 50) | openOptionsPage() {
  method openHelpPage (line 54) | openHelpPage() {
  method openChecklyRunner (line 58) | openChecklyRunner({ code, runner, isLoggedIn }) {

FILE: src/services/selector.js
  function selector (line 3) | function selector(e, { dataAttribute } = {}) {

FILE: src/services/storage.js
  method get (line 2) | get(keys) {
  method set (line 10) | set(props) {
  method remove (line 18) | remove(keys) {

FILE: src/store/index.js
  function clearState (line 5) | function clearState(state) {
  method state (line 16) | state() {
  method showRecorded (line 35) | showRecorded(state) {
  method showCopy (line 40) | showCopy(state) {
  method takeScreenshot (line 45) | takeScreenshot(state) {
  method setDataAttribute (line 49) | setDataAttribute(state, dataAttribute) {
  method setDarkMode (line 53) | setDarkMode(state, darkMode) {
  method setRecording (line 57) | setRecording(state, recording) {
  method unpause (line 61) | unpause(state) {
  method pause (line 66) | pause(state) {
  method close (line 71) | close(state) {
  method restart (line 76) | restart(state) {
  method clear (line 81) | clear(state) {
  method stop (line 85) | stop(state) {
  method copy (line 90) | copy() {
  method toggleScreenshotMode (line 94) | toggleScreenshotMode(state) {
  method startScreenshotMode (line 98) | startScreenshotMode(state, isClipped = false) {
  method stopScreenshotMode (line 107) | stopScreenshotMode(state) {
Condensed preview — 88 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (183K chars).
[
  {
    "path": ".browserslistrc",
    "chars": 30,
    "preview": "> 1%\nlast 2 versions\nnot dead\n"
  },
  {
    "path": ".eslintrc.js",
    "chars": 661,
    "preview": "module.exports = {\n  root: true,\n\n  env: {\n    node: true,\n    webextensions: true,\n  },\n\n  extends: [\n    'plugin:vuejs"
  },
  {
    "path": ".github/auto_assign.yml",
    "chars": 127,
    "preview": "addReviewers: true\naddAssignees: author\n\nreviewers:\n  - pilimartinez\n  - ianaya89\n\nskipKeywords:\n  - wip\n\nnumberOfReview"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 262,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    # Runs at 9am CET only on weekdays\n    schedule:"
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 1155,
    "preview": "# Pull Request Template\n\n## Description\n\nPlease include a summary of the change or which issue is fixed. Also, include r"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "chars": 1623,
    "preview": "name: \"CodeQL\"\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n  schedule:\n    - cron: '38 6 "
  },
  {
    "path": ".github/workflows/lint-build-test.yml",
    "chars": 660,
    "preview": "name: Lint & Build & Test\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    types: [ opened, synchronize ]\n\njobs:\n"
  },
  {
    "path": ".gitignore",
    "chars": 292,
    "preview": ".DS_Store\nnode_modules\n/dist\n\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyar"
  },
  {
    "path": ".npmrc",
    "chars": 16,
    "preview": "save-exact=true\n"
  },
  {
    "path": ".prettierrc.js",
    "chars": 115,
    "preview": "module.exports = {\n  trailingComma: 'es5',\n  tabWidth: 2,\n  semi: false,\n  singleQuote: true,\n  printWidth: 100,\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 1449,
    "preview": "# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changel"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 3232,
    "preview": "# Contributing\n\nHI! Thanks you for your interest in Puppeteer Recorder! We'd love to accept your patches and contributio"
  },
  {
    "path": "LICENSE",
    "chars": 1068,
    "preview": "MIT License\n\nCopyright (c) 2021 Checkly Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "PRIVACY_POLICY.md",
    "chars": 4802,
    "preview": "# Privacy policy\n> Last Updated: July 12, 2021\n\nThe Headless Recorder browser extension (hereinafter “Service”) provided"
  },
  {
    "path": "README.md",
    "chars": 4998,
    "preview": "# 🚨 Deprecated!\nAs of Dec 16th 2022, Headless Recorder is fully deprecated. No new changes, support, maintenance or new "
  },
  {
    "path": "babel.config.js",
    "chars": 66,
    "preview": "module.exports = {\n  presets: ['@vue/cli-plugin-babel/preset'],\n}\n"
  },
  {
    "path": "dependabot.yml",
    "chars": 256,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      time: \"09:00\"\n      interval: \"d"
  },
  {
    "path": "jest.config.js",
    "chars": 231,
    "preview": "module.exports = {\n  preset: '@vue/cli-plugin-unit-jest',\n  transform: {\n    '^.+\\\\.vue$': 'vue-jest',\n    \"^.+\\\\.js$\": "
  },
  {
    "path": "jest.setup.js",
    "chars": 35,
    "preview": "import '@testing-library/jest-dom'\n"
  },
  {
    "path": "jsconfig.json",
    "chars": 39,
    "preview": "{\n  \"include\": [\n    \"./src/**/*\"\n  ]\n}"
  },
  {
    "path": "package.json",
    "chars": 1732,
    "preview": "{\n  \"name\": \"headless-recorder\",\n  \"version\": \"1.1.0\",\n  \"scripts\": {\n    \"serve\": \"vue-cli-service build --mode develop"
  },
  {
    "path": "postcss.config.js",
    "chars": 82,
    "preview": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "public/_locales/en/messages.json",
    "chars": 84,
    "preview": "{\n  \"extName\": {\n    \"message\": \"headless-recorder-v2\",\n    \"description\": \"\"\n  }\n}\n"
  },
  {
    "path": "public/browser-extension.html",
    "chars": 464,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">"
  },
  {
    "path": "src/__tests__/build.spec.js",
    "chars": 303,
    "preview": "import puppeteer from 'puppeteer'\nimport { launchPuppeteerWithExtension } from './helpers'\n\ndescribe('install', () => {\n"
  },
  {
    "path": "src/__tests__/helpers.js",
    "chars": 780,
    "preview": "import path from 'path'\nimport { scripts } from '../../package.json'\nconst util = require('util')\nconst exec = util.prom"
  },
  {
    "path": "src/assets/animations.css",
    "chars": 1000,
    "preview": "@keyframes flash {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n@keyframes slideup {\n  0% {\n    transform:"
  },
  {
    "path": "src/assets/code.css",
    "chars": 1320,
    "preview": ".hljs-line {\n  color: '#ADAACC';\n  margin-right: 8px;\n}\n\n/* Comment */\n.hljs-comment,\n.hljs-quote {\n  color: #d4d0ab;\n}\n"
  },
  {
    "path": "src/assets/tailwind.css",
    "chars": 61,
    "preview": "@tailwind base;\n\n@tailwind components;\n\n@tailwind utilities;\n"
  },
  {
    "path": "src/background/index.js",
    "chars": 8232,
    "preview": "import badge from '@/services/badge'\nimport browser from '@/services/browser'\nimport storage from '@/services/storage'\ni"
  },
  {
    "path": "src/components/Button.vue",
    "chars": 462,
    "preview": "<template>\n  <button\n    class=\"font-semibold text-xs text-gray-darkest inline-flex justify-center items-center rounded-"
  },
  {
    "path": "src/components/Footer.vue",
    "chars": 502,
    "preview": "<template>\n  <div class=\"flex px-4 py-3 justify-between items-center mt-3\">\n    <a href=\"https://checklyhq.com\" target=\""
  },
  {
    "path": "src/components/Header.vue",
    "chars": 678,
    "preview": "<template>\n  <div class=\"flex justify-between items-center p-4 pb-0 mb-2\">\n    <h1 role=\"button\" class=\"text-sm font-sem"
  },
  {
    "path": "src/components/RecordingLabel.vue",
    "chars": 481,
    "preview": "<template>\n  <div\n    data-test-id=\"recording-badge\"\n    class=\"flex text-2xl justify-center items-center text-red font-"
  },
  {
    "path": "src/components/RoundButton.vue",
    "chars": 621,
    "preview": "<template>\n  <button\n    class=\"p-2 bg-white rounded-full border-gray-light border-solid border-4 hover:bg-gray-lightest"
  },
  {
    "path": "src/components/Toggle.vue",
    "chars": 1160,
    "preview": "<template>\n  <div class=\"flex items-center mb-3\">\n    <button\n      type=\"button\"\n      :class=\"[\n        modelValue ? '"
  },
  {
    "path": "src/components/__tests__/RecordingTab.spec.js",
    "chars": 1623,
    "preview": "import { mount } from '@vue/test-utils'\nimport RecordingTab from '../RecordingTab'\n\ndescribe('RecordingTab.vue', () => {"
  },
  {
    "path": "src/components/__tests__/ResultsTab.spec.js",
    "chars": 1214,
    "preview": "import { mount } from '@vue/test-utils'\nimport VueHighlightJS from 'vue3-highlightjs'\n\nimport ResultsTab from '../Result"
  },
  {
    "path": "src/components/__tests__/__snapshots__/RecordingTab.spec.js.snap",
    "chars": 6057,
    "preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`RecordingTab.vue it has the correct pristine / empty state 1`] = `\n"
  },
  {
    "path": "src/components/__tests__/__snapshots__/ResultsTab.spec.js.snap",
    "chars": 1734,
    "preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`RecordingTab.vue it has the correct pristine / empty state 1`] = `\n"
  },
  {
    "path": "src/content-scripts/__tests__/attributes.spec.js",
    "chars": 1997,
    "preview": "import puppeteer from 'puppeteer'\nimport { launchPuppeteerWithExtension } from '@/__tests__/helpers'\nimport { waitForAnd"
  },
  {
    "path": "src/content-scripts/__tests__/fixtures/attributes.html",
    "chars": 463,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <title>forms</title>\n</head>\n<body>\n<div id=\"content-"
  },
  {
    "path": "src/content-scripts/__tests__/fixtures/forms.html",
    "chars": 1667,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <title>forms</title>\n</head>\n<body>\n<form action=\"/ha"
  },
  {
    "path": "src/content-scripts/__tests__/forms.spec.js",
    "chars": 2773,
    "preview": "import puppeteer from 'puppeteer'\nimport _ from 'lodash'\nimport { launchPuppeteerWithExtension } from '@/__tests__/helpe"
  },
  {
    "path": "src/content-scripts/__tests__/helpers.js",
    "chars": 1301,
    "preview": "import express from 'express'\nimport path from 'path'\n\nexport const waitForAndGetEvents = async function(page, amount) {"
  },
  {
    "path": "src/content-scripts/__tests__/screenshot-controller.spec.js",
    "chars": 1141,
    "preview": "import UIController from '../shooter'\n\n// this test NEEDS to come first because of shitty JSDOM.\n// See https://github.c"
  },
  {
    "path": "src/content-scripts/controller.js",
    "chars": 2797,
    "preview": "import { overlayActions } from '@/modules/overlay/constants'\nimport { popupActions, recordingControls, isDarkMode } from"
  },
  {
    "path": "src/content-scripts/index.js",
    "chars": 342,
    "preview": "import store from '@/store'\n\nimport Overlay from '@/modules/overlay'\nimport Recorder from '@/modules/recorder'\n\nimport H"
  },
  {
    "path": "src/manifest.json",
    "chars": 1277,
    "preview": "{\n  \"name\": \"Headless Recorder\",\n  \"version\": \"1.0.0\",\n  \"manifest_version\": 2,\n  \"description\": \"A Chrome extension for"
  },
  {
    "path": "src/modules/code-generator/__tests__/playwright-code-generator.spec.js",
    "chars": 755,
    "preview": "import PlaywrightCodeGenerator from '../playwright'\n\ndescribe('PlaywrightCodeGenerator', () => {\n  test('it should gener"
  },
  {
    "path": "src/modules/code-generator/__tests__/puppeteer-code-generator.spec.js",
    "chars": 5617,
    "preview": "import PuppeteerCodeGenerator from '../puppeteer'\nimport { headlessActions } from '@/services/constants'\n\ndescribe('Pupp"
  },
  {
    "path": "src/modules/code-generator/base-generator.js",
    "chars": 6650,
    "preview": "import Block from '@/modules/code-generator/block'\nimport { headlessActions, eventsToRecord } from '@/modules/code-gener"
  },
  {
    "path": "src/modules/code-generator/block.js",
    "chars": 419,
    "preview": "export default class Block {\n  constructor(frameId, line) {\n    this._lines = []\n    this._frameId = frameId\n\n    if (li"
  },
  {
    "path": "src/modules/code-generator/constants.js",
    "chars": 522,
    "preview": "export const headlessActions = {\n  GOTO: 'GOTO',\n  VIEWPORT: 'VIEWPORT',\n  WAITFORSELECTOR: 'WAITFORSELECTOR',\n  NAVIGAT"
  },
  {
    "path": "src/modules/code-generator/index.js",
    "chars": 528,
    "preview": "import PuppeteerCodeGenerator from '@/modules/code-generator/puppeteer'\nimport PlaywrightCodeGenerator from '@/modules/c"
  },
  {
    "path": "src/modules/code-generator/playwright.js",
    "chars": 1281,
    "preview": "import Block from '@/modules/code-generator/block'\nimport { headlessActions } from '@/modules/code-generator/constants'\n"
  },
  {
    "path": "src/modules/code-generator/puppeteer.js",
    "chars": 1155,
    "preview": "import Block from '@/modules/code-generator/block'\nimport { headlessActions } from '@/modules/code-generator/constants'\n"
  },
  {
    "path": "src/modules/overlay/Overlay.vue",
    "chars": 8924,
    "preview": "<template>\n  <nav\n    v-show=\"!screenshotMode\"\n    :class=\"{\n      'hr-event-recorded': hasRecorded && !isPaused && !isS"
  },
  {
    "path": "src/modules/overlay/Selector.vue",
    "chars": 2366,
    "preview": "<template>\n  <div class=\"overlay\">\n    <div :class=\"selectorClass\" ref=\"selector\"></div>\n  </div>\n</template>\n\n<script>\n"
  },
  {
    "path": "src/modules/overlay/constants.js",
    "chars": 764,
    "preview": "export const overlaySelectors = {\n  OVERLAY_ID: 'headless-recorder-overlay',\n  SELECTOR_ID: 'headless-recorder-selector'"
  },
  {
    "path": "src/modules/overlay/index.js",
    "chars": 2665,
    "preview": "import { createApp } from 'vue'\n\nimport getSelector from '@/services/selector'\nimport SelectorApp from '@/modules/overla"
  },
  {
    "path": "src/modules/recorder/index.js",
    "chars": 3235,
    "preview": "import getSelector from '@/services/selector'\nimport { recordingControls } from '@/services/constants'\nimport { overlayS"
  },
  {
    "path": "src/modules/shooter/index.js",
    "chars": 4640,
    "preview": "import EventEmitter from 'events'\nimport getSelector from '@/services/selector'\nimport { overlayActions, overlaySelector"
  },
  {
    "path": "src/options/OptionsApp.vue",
    "chars": 6978,
    "preview": "<template>\n  <main class=\"bg-gray-lightest flex py-9 w-full h-screen overflow-auto dark:bg-black\">\n    <div class=\"flex "
  },
  {
    "path": "src/options/__tests__/App.spec.js",
    "chars": 2448,
    "preview": "import { mount } from '@vue/test-utils'\nimport App from '../OptionsApp'\n\nfunction createChromeLocalStorageMock(options) "
  },
  {
    "path": "src/options/__tests__/__snapshots__/App.spec.js.snap",
    "chars": 627,
    "preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`App.vue it has the correct pristine / empty state 1`] = `\n<div\n  cl"
  },
  {
    "path": "src/options/main.js",
    "chars": 129,
    "preview": "import { createApp } from 'vue'\nimport App from './OptionsApp.vue'\n\nimport '@/assets/tailwind.css'\n\ncreateApp(App).mount"
  },
  {
    "path": "src/popup/PopupApp.vue",
    "chars": 6837,
    "preview": "<template>\n  <div class=\"bg-gray-lightest dark:bg-black flex flex-col overflow-hidden\">\n    <Header @options=\"openOption"
  },
  {
    "path": "src/popup/__tests__/App.spec.js",
    "chars": 409,
    "preview": "import { shallowMount } from '@vue/test-utils'\nimport App from '../PopupApp'\n\nconst chrome = {\n  storage: {\n    local: {"
  },
  {
    "path": "src/popup/__tests__/__snapshots__/App.spec.js.snap",
    "chars": 1920,
    "preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`App.vue it has the correct pristine / empty state 1`] = `\n<div\n  cl"
  },
  {
    "path": "src/popup/main.js",
    "chars": 227,
    "preview": "import { createApp } from 'vue'\nimport VueHighlightJS from 'vue3-highlightjs'\n\nimport '@/assets/code.css'\nimport '@/asse"
  },
  {
    "path": "src/services/__tests__/analytics.spec.js",
    "chars": 679,
    "preview": "import analytics from '../analytics'\n\nObject.defineProperty(window, '_gaq', {\n  writable: true,\n  value: {\n    push: jes"
  },
  {
    "path": "src/services/__tests__/badge.spec.js",
    "chars": 1528,
    "preview": "import badge from '../badge'\n\nglobal.chrome = {\n  browserAction: {\n    setIcon: jest.fn(),\n    setBadgeText: jest.fn(tex"
  },
  {
    "path": "src/services/__tests__/browser.spec.js",
    "chars": 2808,
    "preview": "import browser from '../browser'\n\nconst activeTab = { id: 1, active: true }\n\nconst copyText = {\n  data: '',\n}\n\nconst coo"
  },
  {
    "path": "src/services/__tests__/constants.spec.js",
    "chars": 638,
    "preview": "import { isDarkMode } from '../constants'\n\nfunction setMatchMediaMock(matches) {\n  Object.defineProperty(window, 'matchM"
  },
  {
    "path": "src/services/__tests__/storage.spec.js",
    "chars": 2758,
    "preview": "import storage from '../storage'\n\nconst store = {\n  token: 'xxx',\n  name: 'lionel',\n}\n\nbeforeEach(() => {\n  window.chrom"
  },
  {
    "path": "src/services/analytics.js",
    "chars": 291,
    "preview": "export default {\n  trackEvent({ event, options }) {\n    if (options?.extension?.telemetry) {\n      window?._gaq?.push(['"
  },
  {
    "path": "src/services/badge.js",
    "chars": 776,
    "preview": "const DEFAULT_COLOR = '#45C8F1'\nconst RECORDING_COLOR = '#FF0000'\n\nconst DEFAULT_LOGO = './images/logo.png'\nconst RECORD"
  },
  {
    "path": "src/services/browser.js",
    "chars": 1946,
    "preview": "const CONTENT_SCRIPT_PATH = 'js/content-script.js'\nconst RUN_URL = 'https://app.checklyhq.com/checks/new/browser'\nconst "
  },
  {
    "path": "src/services/constants.js",
    "chars": 459,
    "preview": "export const recordingControls = {\n  EVENT_RECORDER_STARTED: 'EVENT_RECORDER_STARTED',\n  GET_VIEWPORT_SIZE: 'GET_VIEWPOR"
  },
  {
    "path": "src/services/selector.js",
    "chars": 453,
    "preview": "import { finder } from '@medv/finder/finder.js'\n\nexport default function selector(e, { dataAttribute } = {}) {\n  if (dat"
  },
  {
    "path": "src/services/storage.js",
    "chars": 711,
    "preview": "export default {\n  get(keys) {\n    if (!chrome.storage || !chrome.storage.local) {\n      return Promise.reject('Browser "
  },
  {
    "path": "src/store/index.js",
    "chars": 2747,
    "preview": "import { createStore } from 'vuex'\n\nimport { overlayActions } from '@/modules/overlay/constants'\n\nfunction clearState(st"
  },
  {
    "path": "src/views/Home.vue",
    "chars": 641,
    "preview": "<template>\n  <div class=\"flex flex-col items-center rounded-md pt-10 h-100\">\n    <h3 class=\"text-gray-darkest text-xl fo"
  },
  {
    "path": "src/views/Recording.vue",
    "chars": 2166,
    "preview": "<template>\n  <section class=\"flex flex-col items-center rounded-md pt-8 h-full\">\n    <RecordingLabel class=\"w-1/3\" :is-p"
  },
  {
    "path": "src/views/Results.vue",
    "chars": 2248,
    "preview": "<template>\n  <div\n    data-test-id=\"results-tab\"\n    class=\"flex flex-col bg-blue-light overflow-hidden mt-4 h-100 dark:"
  },
  {
    "path": "tailwind.config.js",
    "chars": 25392,
    "preview": "const colors = require('tailwindcss/colors')\n\nmodule.exports = {\n  purge: { content: ['./public/**/*.html', './src/**/*."
  },
  {
    "path": "vue.config.js",
    "chars": 634,
    "preview": "module.exports = {\n  css: {\n    extract: false,\n  },\n\n  pages: {\n    popup: {\n      template: 'public/browser-extension."
  }
]

About this extraction

This page contains the full source code of the checkly/headless-recorder GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 88 files (165.5 KB), approximately 47.2k tokens, and a symbol index with 138 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!