refactor: update code to Vue 3 and refresh test cases

This commit is contained in:
Ignacio Anaya
2021-06-10 11:01:48 -03:00
parent 5215e22237
commit 97d94f276b
87 changed files with 17257 additions and 936 deletions

7
jest.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
preset: '@vue/cli-plugin-unit-jest',
transform: {
'^.+\\.vue$': 'vue-jest',
},
setupFilesAfterEnv: ['./jest.setup.js'],
}

1
jest.setup.js Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

12637
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,23 +5,43 @@
"scripts": {
"serve": "vue-cli-service build --mode development --watch",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit __tests__/.*.spec.js",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@medv/finder": "1.1.2",
"@tailwindcss/postcss7-compat": "2.0.2",
"@vueuse/core": "4.0.8",
"autoprefixer": "9",
"core-js": "3.6.5",
"vue": "3.0.0"
"postcss": "7",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@2.0.2",
"vue": "3.0.6",
"vue3-highlightjs": "1.0.5"
},
"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",
"babel-eslint": "10.1.0",
"eslint": "6.7.2",
"eslint-plugin-prettier": "3.1.3",
"eslint-plugin-vue": "7.0.0-0",
"eslint-plugin-vue": "7.10.0",
"jest": "26.6.3",
"jest-vue-preprocessor": "1.7.1",
"node-sass": "5.0.0",
"playwright": "1.10.0",
"prettier": "1.19.1",
"vue-cli-plugin-browser-extension": "0.25.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": "5.0.0-alpha.9"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -1,6 +0,0 @@
{
"extName": {
"message": "headless-recorder-v2",
"description": ""
}
}

View File

@@ -1,12 +0,0 @@
<!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>
</head>
<body>
<div id="app"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<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">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/images/icon_rec.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/images/icon_wait.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,10 @@
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);
});

View File

@@ -0,0 +1,30 @@
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);
};

View File

@@ -0,0 +1,252 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="110px"
height="110px" viewBox="0 0 110 110" style="enable-background:new 0 0 110 110;" xml:space="preserve">
<g id="Artboard" style="display:none;">
<rect x="-963" y="-178" style="display:inline;fill:#808080;" width="1376" height="359"/>
</g>
<g id="R-Multicolor" style="display:none;">
<circle style="display:inline;fill:#32BEA6;" cx="55" cy="55" r="55"/>
<g style="display:inline;">
<path style="fill:#EDBC7C;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
<path style="fill:#F8E1C2;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.796,0,1.559,0.316,2.121,0.879
L87,83H23z"/>
<circle style="fill:#FACB1B;" cx="31" cy="31" r="8"/>
<path style="fill:#107665;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.248-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
<path style="fill:#0D9681;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.248-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
<g id="Multicolor">
<g>
<path style="fill:#EDBC7C;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
<path style="fill:#F8E1C2;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.796,0,1.559,0.316,2.121,0.879
L87,83H23z"/>
<circle style="fill:#FACB1B;" cx="31" cy="31" r="8"/>
<path style="fill:#107665;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.248-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
<path style="fill:#0D9681;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.248-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
<g id="Blue" style="display:none;">
<g style="display:inline;">
<g>
<path style="fill:#53BAD4;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#BBE7F2;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#0081A1;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#009FC7;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
<g id="R-Blue" style="display:none;">
<g style="display:inline;">
<circle style="fill:#81D2EB;" cx="55" cy="55" r="55"/>
</g>
<g style="display:inline;">
<g>
<path style="fill:#53BAD4;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#BBE7F2;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#0081A1;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#009FC7;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
<g id="Green" style="display:none;">
<g style="display:inline;">
<g>
<path style="fill:#5DCFC3;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#AAF0E9;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#009687;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#00B8A5;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
<g id="R-Green" style="display:none;">
<g style="display:inline;">
<circle style="fill:#87E0C8;" cx="55" cy="55" r="55"/>
</g>
<g style="display:inline;">
<g>
<path style="fill:#5DCFC3;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#AAF0E9;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#009687;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#00B8A5;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
<g id="Red" style="display:none;">
<g style="display:inline;">
<g>
<path style="fill:#E8A099;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#FFD7D4;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#C23023;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#E54B44;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
<g id="R-Red" style="display:none;">
<g style="display:inline;">
<circle style="fill:#FABBAF;" cx="55" cy="55" r="55"/>
</g>
<g style="display:inline;">
<g>
<path style="fill:#E8A099;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#FFD7D4;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#C23023;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#E54B44;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
<g id="Yellow" style="display:none;">
<g style="display:inline;">
<g>
<path style="fill:#F5C43D;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#FFE9A1;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#E07000;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#FA9200;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
<g id="R-Yellow" style="display:none;">
<g style="display:inline;">
<circle style="fill:#FFD75E;" cx="55" cy="55" r="55"/>
</g>
<g style="display:inline;">
<g>
<path style="fill:#F5C43D;" d="M83.879,79.879C83.316,79.316,82.553,79,81.757,79H28.243c-0.795,0-1.559,0.316-2.122,0.879L23,83
v4h64v-4L83.879,79.879z"/>
</g>
<g>
<path style="fill:#FFE9A1;" d="M23,83l3.121-3.121C26.684,79.316,27.447,79,28.243,79h53.515c0.795,0,1.559,0.316,2.122,0.879
L87,83H23z"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="31" cy="31" r="8"/>
</g>
<g>
<path style="fill:#E07000;" d="M76,35c-3.59,0-6,2.81-6,5.94v4.08c0,1.09-0.89,1.98-1.98,1.98h-0.04C66.89,47,66,46.11,66,45.02
V31c0-4.42-3.58-8-8-8s-8,3.58-8,8v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h16V71c0-12.17,16-7.83,16-19.85V40.94
C82,37.66,79.28,35,76,35z"/>
</g>
<g>
<path style="fill:#FA9200;" d="M50,31v22.02c0,1.09-0.89,1.98-1.98,1.98h-0.04C46.89,55,46,54.11,46,53.02v-4.08
c0-3.247-2.54-5.94-6-5.94c-3.427,0-6,2.658-6,5.94v10.21C34,71.17,50,66.83,50,79v4h8V23C53.58,23,50,26.58,50,31z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
<title>_ionicons_svg_ios-help-circle</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="_ionicons_svg_ios-help-circle" fill="#8492A6" fill-rule="nonzero">
<path d="M16,0 C7.16153846,0 0,7.16153846 0,16 C0,24.8384615 7.16153846,32 16,32 C24.8384615,32 32,24.8384615 32,16 C32,7.16153846 24.8384615,0 16,0 Z M15.6692308,23.3846154 C14.7615385,23.3846154 14.0230769,22.6923077 14.0230769,21.8 C14.0230769,20.9153846 14.7615385,20.2153846 15.6692308,20.2153846 C16.5846154,20.2153846 17.3230769,20.9076923 17.3230769,21.8 C17.3230769,22.6923077 16.5923077,23.3846154 15.6692308,23.3846154 Z M18.7615385,15.9307692 C17.4230769,16.7076923 16.9692308,17.2769231 16.9692308,18.2615385 L16.9692308,18.8692308 L14.3,18.8692308 L14.2769231,18.2076923 C14.1461538,16.6230769 14.7,15.6384615 16.0923077,14.8230769 C17.3923077,14.0461538 17.9384615,13.5538462 17.9384615,12.6 C17.9384615,11.6461538 17.0153846,10.9461538 15.8692308,10.9461538 C14.7076923,10.9461538 13.8692308,11.7 13.8076923,12.8384615 L11.0769231,12.8384615 C11.1307692,10.3615385 12.9615385,8.60769231 16.0538462,8.60769231 C18.9384615,8.60769231 20.9230769,10.2076923 20.9230769,12.5076923 C20.9230769,14.0384615 20.1846154,15.0923077 18.7615385,15.9307692 Z" id="Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
<title>_ionicons_svg_ios-settings</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="_ionicons_svg_ios-settings" fill="#8492A6" fill-rule="nonzero">
<path d="M29.3583333,16 C29.3583333,14.25 30.45,12.7583333 32,12.1583333 C31.5916667,10.45 30.9166667,8.85 30.025,7.4 C29.4916667,7.63333333 28.925,7.75833333 28.35,7.75833333 C27.3,7.75833333 26.25,7.35833333 25.4416667,6.55833333 C24.2,5.31666667 23.925,3.49166667 24.5916667,1.975 C23.15,1.08333333 21.5416667,0.408333333 19.8416667,1.18423789e-15 C19.25,1.54166667 17.75,2.64166667 16,2.64166667 C14.25,2.64166667 12.75,1.54166667 12.1583333,0 C10.45,0.408333333 8.85,1.08333333 7.4,1.975 C8.075,3.48333333 7.79166667,5.31666667 6.55,6.55833333 C5.75,7.35833333 4.69166667,7.75833333 3.64166667,7.75833333 C3.06666667,7.75833333 2.5,7.64166667 1.96666667,7.4 C1.08333333,8.85833333 0.408333333,10.4583333 0,12.1666667 C1.54166667,12.7583333 2.64166667,14.25 2.64166667,16.0083333 C2.64166667,17.7583333 1.55,19.25 0.00833333333,19.85 C0.416666667,21.5583333 1.09166667,23.1583333 1.98333333,24.6083333 C2.51666667,24.375 3.08333333,24.2583333 3.65,24.2583333 C4.7,24.2583333 5.75,24.6583333 6.55833333,25.4583333 C7.79166667,26.6916667 8.075,28.525 7.40833333,30.0333333 C8.85833333,30.925 10.4666667,31.6 12.1666667,32.0083333 C12.7583333,30.4666667 14.25,29.375 16,29.375 C17.75,29.375 19.2416667,30.4666667 19.8333333,32.0083333 C21.5416667,31.6 23.1416667,30.925 24.5916667,30.0333333 C23.925,28.525 24.2083333,26.7 25.4416667,25.4583333 C26.2416667,24.6583333 27.2916667,24.2583333 28.35,24.2583333 C28.9166667,24.2583333 29.4916667,24.375 30.0166667,24.6083333 C30.9083333,23.1583333 31.5833333,21.55 31.9916667,19.85 C30.4583333,19.25 29.3583333,17.7583333 29.3583333,16 Z M16.075,22.6583333 C12.3833333,22.6583333 9.40833333,19.6666667 9.40833333,15.9916667 C9.40833333,12.3166667 12.3833333,9.325 16.075,9.325 C19.7666667,9.325 22.7416667,12.3166667 22.7416667,15.9916667 C22.7416667,19.6666667 19.7666667,22.6583333 16.075,22.6583333 Z" id="Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,163 @@
.shake {
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
transform: translate3d(0, 0, 0);
}
@keyframes shake {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-4px, 0, 0);
}
40%, 60% {
transform: translate3d(4px, 0, 0);
}
}
.pulse {
animation: pulse 2s ease infinite;
}
@keyframes pulse {
0% {
opacity: .7;
}
50% {
opacity: .4;
}
100% {
opacity: .7;
}
}
.flash-once {
animation: flash-once 3.5s ease 1;
}
@keyframes fade-up {
0% {
transform: translate3d(0, 10px, 0);
opacity: 0;
}
100% {
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
.fade-in {
animation: fade-in .3s ease-in-out;
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.spin {
animation-name: spin;
animation-duration: 2000ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes spin {
from {transform:rotate(0deg);}
to {transform:rotate(360deg);}
}
.bounceIn {
animation-name: bounceIn;
transform-origin: center bottom;
animation-duration: 1s;
animation-fill-mode: both;
animation-iteration-count: 1;
}
@keyframes bounceIn {
0%, 20%, 40%, 60%, 80%, 100% {
-webkit-transition-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
transition-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
}
0% {
opacity: 1;
-webkit-transform: scale3d(.8, .8, .8);
transform: scale3d(.8, .8, .8);
}
20% {
-webkit-transform: scale3d(1.1, 1.1, 1.1);
transform: scale3d(1.1, 1.1, 1.1);
}
40% {
-webkit-transform: scale3d(.9, .9, .9);
transform: scale3d(.9, .9, .9);
}
60% {
opacity: 1;
-webkit-transform: scale3d(1.03, 1.03, 1.03);
transform: scale3d(1.03, 1.03, 1.03);
}
80% {
-webkit-transform: scale3d(.97, .97, .97);
transform: scale3d(.97, .97, .97);
}
100% {
opacity: 1;
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
@keyframes dots {
0%, 20% {
color: rgba(0,0,0,0);
text-shadow:
.25em 0 0 rgba(0,0,0,0),
.5em 0 0 rgba(0,0,0,0);}
40% {
color: #8492A6;
text-shadow:
.25em 0 0 rgba(0,0,0,0),
.5em 0 0 rgba(0,0,0,0);}
60% {
text-shadow:
.25em 0 0 #8492A6,
.5em 0 0 rgba(0,0,0,0);}
80%, 100% {
text-shadow:
.25em 0 0 #8492A6,
.5em 0 0 #8492A6;}}
@keyframes recording {
0% {
box-shadow: 0px 0px 5px 0px rgba(173,0,0,.3);
}
65% {
box-shadow: 0px 0px 5px 5px rgba(173,0,0,.3);
}
90% {
box-shadow: 0px 0px 5px 5px rgba(173,0,0,0);
}
}

View File

@@ -0,0 +1,39 @@
button {
&.btn {
display: inline-block;
font-weight: 300;
line-height: 1.25;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 1px solid transparent;
cursor: pointer;
letter-spacing: 1px;
transition: all .15s ease;
&.btn-sm {
padding: .4rem .8rem;
font-size: .8rem;
border-radius: .2rem
}
&.btn-primary {
color: #fff;
background-color: $brand-primary;
border-color: $brand-primary;
}
&.btn-outline-primary {
color: $brand-primary;
background-color: transparent;
border-color: $brand-primary
}
&.btn-danger {
color: #fff;
background-color: $brand-danger;
border-color: $brand-danger;
}
}
}

View File

@@ -0,0 +1,19 @@
@mixin header () {
background: $gray-lightest;
height: 48px;
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0 $spacer;
font-weight: 500;
}
@mixin footer() {
background: $gray-lightest;
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 $spacer;
font-weight: 500;
border-top: 1px solid $gray-light;
}

View File

@@ -0,0 +1,21 @@
// drop down
.drop-down-enter, .drop-down-leave-to {
transform: translateX(0) translateY(-20px);
transition-timing-function: cubic-bezier(.74,.04,.26,1.05);
opacity: 0;
}
.drop-down-enter-active, .drop-down-leave-active {
transition: all .15s
}
// move left
.move-left-enter, .move-left-leave-to {
transform: translateY(0) translateX(-80px);
transition-timing-function: cubic-bezier(.74,.04,.26,1.05);
opacity: 0;
}
.move-left-enter-active, .move-left-leave-active {
transition: all .15s
}

View File

@@ -0,0 +1,8 @@
.text-muted {
color: $gray;
}
.text-center {
text-align: center;
}

View File

@@ -0,0 +1,3 @@
.w-100 {
width: 100%;
}

View File

@@ -0,0 +1,38 @@
// spacing
$spacer: 12px;
// colors
$white: #fff;
$black: #1f2d3d;
$red: #FF4949;
$orange: #F19B45;
$yellow: #FFC82C;
$green: #13CE66;
$blue: #45C8F1;
$blue-light: #EBFAFF;
$blue-lightest: #F0F8FF;
$teal: #205E71;
$pink: #FF659D;
$gray-dark: #3C4858;
$gray: #8492A6;
$gray-light: #E0E6ED;
$gray-lighter: #EFF2F7;
$gray-lightest: #F9FAFC;
$brand-primary: $blue;
$brand-success: $green;
$brand-info: $teal;
$brand-warning: $orange;
$brand-danger: $red;
$brand-inverse: $gray-dark;
$brand-accent: $pink;
// typography
$text-muted: $gray;
// layout
$max-content-height: 400px;

View File

@@ -0,0 +1,71 @@
/**
* GitHub Gist Theme
* Author : Louis Barranqueiro - https://github.com/LouisBarranqueiro
*/
.hljs {
display: block;
background: white;
padding: 0.5em;
color: #333333;
overflow-x: auto;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: #df5000;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #a71d5d;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}

View File

@@ -0,0 +1,30 @@
@import "variables";
@import "buttons";
@import "typography";
@import "transitions";
@import "animations";
@import "github-gist.css";
@import "mixins";
//reset
html {
max-height: 500px;
}
body {
margin: 0;
font-size: 100%;
color: $gray-dark;
width: 350px;
max-height: 500px;
}
a {
text-decoration: none;
color: $brand-primary;
}
h1, h2, h3, h4 {
margin-top: 0;
}

5
src/assets/tailwind.css Normal file
View File

@@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,7 +1,272 @@
browser.runtime.onMessage.addListener(function() {
console.log("Hello from the background");
import pptrActions from "@/services/pptr-actions";
import ctrl from "@/models/extension-control-messages";
import actions from "@/models/extension-ui-actions";
browser.tabs.executeScript({
file: "content-script.js"
});
});
class RecordingController {
constructor() {
this._recording = [];
this._boundedMessageHandler = null;
this._boundedNavigationHandler = null;
this._boundedWaitHandler = null;
this._boundedMenuHandler = null;
this._boundedKeyCommandHandler = null;
this._badgeState = "";
this._isPaused = false;
// 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;
this._menuId = "PUPPETEER_RECORDER_CONTEXT_MENU";
this._menuOptions = {
SCREENSHOT: "SCREENSHOT",
SCREENSHOT_CLIPPED: "SCREENSHOT_CLIPPED"
};
}
boot() {
chrome.extension.onConnect.addListener(port => {
console.debug("listeners connected");
port.onMessage.addListener(msg => {
if (msg.action && msg.action === actions.START) this.start();
if (msg.action && msg.action === actions.STOP) this.stop();
if (msg.action && msg.action === actions.CLEAN_UP) this.cleanUp();
if (msg.action && msg.action === actions.PAUSE) this.pause();
if (msg.action && msg.action === actions.UN_PAUSE) this.unPause();
});
});
}
start() {
console.debug("start recording");
this.cleanUp(() => {
this._badgeState = "rec";
this._hasGoto = false;
this._hasViewPort = false;
this.injectScript();
this._boundedMessageHandler = this.handleMessage.bind(this);
this._boundedNavigationHandler = this.handleNavigation.bind(this);
this._boundedWaitHandler = this.handleWait.bind(this);
chrome.runtime.onMessage.addListener(this._boundedMessageHandler);
chrome.webNavigation.onCompleted.addListener(
this._boundedNavigationHandler
);
chrome.webNavigation.onBeforeNavigate.addListener(
this._boundedWaitHandler
);
chrome.browserAction.setIcon({ path: "./images/icon-green.png" });
chrome.browserAction.setBadgeText({ text: this._badgeState });
chrome.browserAction.setBadgeBackgroundColor({ color: "#FF0000" });
/**
* Right click menu setup
*/
chrome.contextMenus.removeAll();
// add the parent and its children
chrome.contextMenus.create({
id: this._menuId,
title: "Headless Recorder",
contexts: ["all"]
});
chrome.contextMenus.create({
id: this._menuId + this._menuOptions.SCREENSHOT,
title: "Take Screenshot (Ctrl+Shift+A)",
parentId: this._menuId,
contexts: ["all"]
});
chrome.contextMenus.create({
id: this._menuId + this._menuOptions.SCREENSHOT_CLIPPED,
title: "Take Screenshot Clipped (Ctrl+Shift+S)",
parentId: this._menuId,
contexts: ["all"]
});
// add the handlers
this._boundedMenuHandler = this.handleMenuInteraction.bind(this);
chrome.contextMenus.onClicked.addListener(this._boundedMenuHandler);
this._boundedKeyCommandHandler = this.handleKeyCommands.bind(this);
chrome.commands.onCommand.addListener(this._boundedKeyCommandHandler);
});
}
stop() {
console.debug("stop recording");
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);
chrome.browserAction.setIcon({ path: "./images/icon-black.png" });
chrome.browserAction.setBadgeText({ text: this._badgeState });
chrome.browserAction.setBadgeBackgroundColor({ color: "#45C8F1" });
chrome.storage.local.set({ recording: this._recording }, () => {
console.debug("recording stored");
});
}
pause() {
console.debug("pause");
this._badgeState = "❚❚";
chrome.browserAction.setBadgeText({ text: this._badgeState });
this._isPaused = true;
}
unPause() {
console.debug("unpause");
this._badgeState = "rec";
chrome.browserAction.setBadgeText({ text: this._badgeState });
this._isPaused = false;
}
cleanUp(cb) {
console.debug("cleanup");
this._recording = [];
chrome.browserAction.setBadgeText({ text: "" });
chrome.storage.local.remove("recording", () => {
console.debug("stored recording cleared");
if (cb) cb();
});
}
recordCurrentUrl(href) {
if (!this._hasGoto) {
console.debug("recording goto* for:", href);
this.handleMessage({
selector: undefined,
value: undefined,
action: pptrActions.GOTO,
href
});
this._hasGoto = true;
}
}
recordCurrentViewportSize(value) {
if (!this._hasViewPort) {
this.handleMessage({
selector: undefined,
value,
action: pptrActions.VIEWPORT
});
this._hasViewPort = true;
}
}
recordNavigation() {
this.handleMessage({
selector: undefined,
value: undefined,
action: pptrActions.NAVIGATION
});
}
recordScreenshot(value) {
this.handleMessage({
selector: undefined,
value,
action: pptrActions.SCREENSHOT
});
}
handleMessage(msg, sender) {
if (msg.control) return this.handleControlMessage(msg, sender);
if (msg.type === "SIGN_CONNECT") {
return;
}
// 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);
console.log(msg);
chrome.storage.local.set({ recording: this._recording }, () => {
console.debug("stored recording updated");
});
}
}
handleControlMessage(msg) {
if (msg.control === ctrl.EVENT_RECORDER_STARTED)
chrome.browserAction.setBadgeText({ text: this._badgeState });
if (msg.control === ctrl.GET_VIEWPORT_SIZE)
this.recordCurrentViewportSize(msg.coordinates);
if (msg.control === ctrl.GET_CURRENT_URL) this.recordCurrentUrl(msg.href);
if (msg.control === ctrl.GET_SCREENSHOT) this.recordScreenshot(msg.value);
}
handleNavigation({ frameId }) {
console.debug("frameId is:", frameId);
this.injectScript();
if (frameId === 0) {
this.recordNavigation();
}
}
handleMenuInteraction(info) {
console.debug("context menu clicked");
switch (info.menuItemId) {
case this._menuId + this._menuOptions.SCREENSHOT:
this.toggleScreenShotMode(actions.TOGGLE_SCREENSHOT_MODE);
break;
case this._menuId + this._menuOptions.SCREENSHOT_CLIPPED:
this.toggleScreenShotMode(actions.TOGGLE_SCREENSHOT_CLIPPED_MODE);
break;
}
}
handleKeyCommands(command) {
switch (command) {
case actions.TOGGLE_SCREENSHOT_MODE:
this.toggleScreenShotMode(actions.TOGGLE_SCREENSHOT_MODE);
break;
case actions.TOGGLE_SCREENSHOT_CLIPPED_MODE:
this.toggleScreenShotMode(actions.TOGGLE_SCREENSHOT_CLIPPED_MODE);
break;
}
}
toggleScreenShotMode(action) {
console.debug("toggling screenshot mode");
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
chrome.tabs.sendMessage(tabs[0].id, { action });
});
}
handleWait() {
chrome.browserAction.setBadgeText({ text: "wait" });
}
injectScript() {
chrome.tabs.executeScript({
file: "js/content-script.js",
allFrames: true
});
}
}
console.debug("booting recording controller");
window.recordingController = new RecordingController();
window.recordingController.boot();

View File

@@ -0,0 +1,29 @@
<template>
<div class="checkly-badge text-muted">
powered by
<a href="https://checklyhq.com" target="_blank">
<img src="@/assets/images/text_racoon_logo.svg" alt="Checkly logo" />
</a>
</div>
</template>
<script>
export default {
name: "ChecklyBadge"
};
</script>
<style scoped>
.checkly-badge {
display: flex;
justify-content: flex-end;
align-items: center;
font-weight: normal;
}
img {
margin-left: 8px;
width: 80px;
vertical-align: middle;
}
</style>

103
src/components/HelpTab.vue Normal file
View File

@@ -0,0 +1,103 @@
<template>
<div class="tab help-tab">
<div class="content">
<h4>Recording</h4>
<ul>
<li>To start recording hit <strong>Record</strong></li>
<li>
Hit <kbd>tab</kbd> after you finish typing in an
<kbd>input</kbd> element.
</li>
<li>Click links, inputs and other elements.</li>
<li>
Wait for full page load on each navigation. The icon will switch from
<img src="@/assets/images/icon_rec.png" /> to
<img src="@/assets/images/icon_wait.png" />.
</li>
<li>
Click <strong>Pause</strong> when you want to navigate without
recording anything. Hit <strong>Resume</strong> to continue recording.
</li>
</ul>
<h4>Controls</h4>
<p>
While recording, right click to show extra controls that trigger various
functions like recording screenshots.
<img
src="@/assets/images/context_menu.png"
alt="context menu"
class="w-100"
/>
</p>
<h4>Keyboard shortcuts</h4>
<ul>
<li>Take screenshot: Ctrl+Shift+A</li>
<li>Take clipped screenshot: Ctrl+Shift+M</li>
</ul>
<p>
For more help and examples,
<a href="https://checklyhq.com/headless-recorder" target="_blank"
>go to the help docs</a
>
</p>
<h4>Replaying</h4>
<p>
Install
<a href="https://github.com/GoogleChrome/puppeteer">Puppeteer</a> or
<a href="https://playwright.dev/">Playwright</a> on your machine. Copy
and paste the code into a file and run as a standard node program
</p>
<pre>
npm install puppeteer playwright
node my-script.js</pre
>
</div>
<div class="help-footer text-muted">
powered by
<a href="https://checklyhq.com" target="_blank">
<img src="@/assets/images/text_racoon_logo.svg" alt="" />
</a>
</div>
</div>
</template>
<script>
export default {
name: "HelpTab"
};
</script>
<style lang="scss" scoped>
@import "../assets/styles/_variables.scss";
@import "../assets/styles/_mixins.scss";
@import "../assets/styles/_utils.scss";
.help-tab {
.content {
padding: $spacer;
text-align: left;
ul {
padding-left: 1rem;
}
pre {
background: #272822;
color: white;
font-family: monospace;
padding: 1rem;
border-radius: 4px;
}
}
.help-footer {
@include footer();
font-weight: normal;
justify-content: flex-end;
img {
margin-left: 8px;
width: 80px;
vertical-align: middle;
}
}
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="tab recording-tab">
<div class="content">
<div class="empty" v-show="!isRecording">
<img src="@/assets/images/Desert.svg" alt="desert" width="78px" />
<h3>No recorded events yet</h3>
<p class="text-muted">Click record to begin</p>
<div class="nag-cta" v-show="!isRecording">
<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" v-show="isRecording">
<p
class="text-muted text-center loading"
v-show="liveEvents.length === 0"
>
Waiting for events
</p>
<ul class="event-list">
<li
v-for="(event, index) in liveEvents"
:key="index"
class="event-list-item"
>
<div class="event-label">{{ index + 1 }}.</div>
<div class="event-description">
<div class="event-action">{{ event.action }}</div>
<div class="event-props text-muted">
{{ event.selector || parseEventValue(event) }}
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "RecordingTab",
props: {
isRecording: { type: Boolean, default: false },
liveEvents: {
type: Array,
default: () => {
return [];
}
}
},
methods: {
parseEventValue(event) {
if (!event) {
return;
}
if (event.action === "viewport*")
return `width: ${event.value.width}, height: ${event.value.height}`;
if (event.action === "goto*") return event.href;
if (event.action === "navigation*") return "";
}
}
};
</script>
<style lang="scss" scoped>
@import "../assets/styles/_animations.scss";
@import "../assets/styles/_variables.scss";
.recording-tab {
.content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 200px;
.empty {
padding: $spacer;
text-align: center;
}
.events {
max-height: $max-content-height;
flex: 1;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column-reverse;
.loading:after {
content: ".";
animation: dots 1s steps(5, end) infinite;
animation-delay: 1.5s;
margin-bottom: auto;
}
.event-list {
list-style-type: none;
padding: 0;
margin: 0;
.event-list-item {
padding: 12px;
font-size: 12px;
border-top: 1px solid $gray-light;
display: flex;
flex: 1 1 auto;
height: 32px;
.event-label {
vertical-align: top;
margin-right: $spacer;
}
.event-description {
margin-right: auto;
display: inline-block;
.event-action {
font-weight: bold;
}
.event-props {
white-space: pre;
}
}
}
}
}
}
.nag-cta {
margin-bottom: $spacer;
a {
color: $pink;
font-size: 80%;
font-weight: 500;
}
}
}
</style>

View File

@@ -0,0 +1,287 @@
<template>
<div class="tab results-tab">
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab"
class="tabs__action"
v-bind:class="{ selected: activeTab === tab }"
@click.prevent="changeTab(tab)"
>
<span v-if="tab === 'playwright'">🎭</span>
<img
v-if="tab === 'puppeteer'"
src="@/assets/images/puppeteer.png"
width="16"
/>
<span class="tabs__action--text">{{ tab }}</span>
</button>
</div>
<div class="content">
<pre v-if="code()" v-highlightjs="code()">
<code class="javascript">
</code>
</pre>
<pre v-else>
<code>No code yet...</code>
</pre>
</div>
</div>
</template>
<script>
export const TYPE = {
PUPPETEER: "puppeteer",
PLAYWRIGHT: "playwright"
};
export default {
name: "ResultsTab",
props: {
puppeteer: {
type: String,
default: ""
},
playwright: {
type: String,
default: ""
},
options: {
type: Object,
default: () => ({})
}
},
data() {
return {
activeTab: TYPE.PUPPETEER,
tabs: [TYPE.PUPPETEER, TYPE.PLAYWRIGHT]
};
},
mounted() {
if (
this.options &&
this.options.code &&
this.options.code.showPlaywrightFirst
) {
this.activeTab = TYPE.PLAYWRIGHT;
this.tabs = this.tabs.reverse();
}
this.$emit("update:tab", this.activeTab);
},
methods: {
code() {
return this.activeTab === TYPE.PUPPETEER
? this.puppeteer
: this.playwright;
},
changeTab(tab) {
this.activeTab = tab;
this.$emit("update:tab", tab);
}
}
};
</script>
<style lang="scss" scoped>
@import "../assets/styles/_variables.scss";
.results-tab {
.content {
display: flex;
flex-direction: column;
height: 100%;
.generated-code {
flex: 1;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column-reverse;
max-height: $max-content-height;
}
pre {
padding: 0 $spacer;
font-size: 12px;
white-space: pre-wrap;
}
code {
font-family: Consolas, Monaco, monospace;
padding: $spacer;
}
}
}
.tabs {
display: flex;
border-bottom: 1px solid $gray-lighter;
&__action {
padding: 12px 20px;
border: 0;
background: transparent;
cursor: pointer;
font-size: 14px;
color: $gray-dark;
outline: none;
border-bottom: 4px solid transparent;
text-transform: capitalize;
display: flex;
align-items: center;
&--text {
margin-left: 10px;
}
&.selected {
border-bottom-color: $blue;
}
}
}
.hljs {
background: #011627;
color: #d6deeb;
}
/* General Purpose */
.hljs-keyword {
color: #c792ea;
font-style: italic;
}
.hljs-built_in {
color: #addb67;
font-style: italic;
}
.hljs-type {
color: #82aaff;
}
.hljs-literal {
color: #ff5874;
}
.hljs-number {
color: #f78c6c;
}
.hljs-regexp {
color: #5ca7e4;
}
.hljs-string {
color: #ecc48d;
}
.hljs-subst {
color: #d3423e;
}
.hljs-symbol {
color: #82aaff;
}
.hljs-class {
color: #ffcb8b;
}
.hljs-function {
color: #82aaff;
}
.hljs-title {
color: #dcdcaa;
font-style: italic;
}
.hljs-params {
color: #7fdbca;
}
/* Meta */
.hljs-comment {
color: #637777;
font-style: italic;
}
.hljs-doctag {
color: #7fdbca;
}
.hljs-meta {
color: #82aaff;
}
.hljs-meta-keyword {
color: #82aaff;
}
.hljs-meta-string {
color: #ecc48d;
}
/* Tags, attributes, config */
.hljs-section {
color: #82b1ff;
}
.hljs-tag,
.hljs-name {
color: #7fdbca;
}
.hljs-attr {
color: #7fdbca;
}
.hljs-attribute {
color: #80cbc4;
}
.hljs-variable {
color: #addb67;
}
/* Markup */
.hljs-bullet {
color: #d9f5dd;
}
.hljs-code {
color: #80cbc4;
}
.hljs-emphasis {
color: #c792ea;
font-style: italic;
}
.hljs-strong {
color: #addb67;
font-weight: bold;
}
.hljs-formula {
color: #c792ea;
}
.hljs-link {
color: #ff869a;
}
.hljs-quote {
color: #697098;
font-style: italic;
}
/* CSS */
.hljs-selector-tag {
color: #ff6363;
}
.hljs-selector-id {
color: #fad430;
}
.hljs-selector-class {
color: #addb67;
font-style: italic;
}
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #c792ea;
font-style: italic;
}
/* Templates */
.hljs-template-tag {
color: #c792ea;
}
.hljs-template-variable {
color: #addb67;
}
/* diff */
.hljs-addition {
color: #addb67ff;
font-style: italic;
}
.hljs-deletion {
color: #ef535090;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,57 @@
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()
})
})

View File

@@ -0,0 +1,41 @@
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')
})
})

View File

@@ -0,0 +1,328 @@
// 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>
`;

View File

@@ -0,0 +1,118 @@
// 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>
`;

View File

@@ -0,0 +1,197 @@
import eventsToRecord from "@/services/dom-events-to-record";
import UIController from "./UIController";
import actions from "@/models/extension-ui-actions";
import ctrl from "@/models/extension-control-messages";
import finder from "@medv/finder";
const DEFAULT_MOUSE_CURSOR = "default";
export default class EventRecorder {
constructor() {
this._boundedMessageListener = null;
this._eventLog = [];
this._previousEvent = null;
this._dataAttribute = null;
this._uiController = null;
this._screenShotMode = false;
this._isTopFrame = window.location === window.parent.location;
this._isRecordingClicks = true;
}
boot() {
// We need to check the existence of chrome for testing purposes
if (chrome.storage && chrome.storage.local) {
chrome.storage.local.get(["options"], ({ options }) => {
const { dataAttribute } = options ? options.code : {};
if (dataAttribute) {
this._dataAttribute = dataAttribute;
}
this._initializeRecorder();
});
} else {
this._initializeRecorder();
}
}
_initializeRecorder() {
const events = Object.values(eventsToRecord);
if (!window.pptRecorderAddedControlListeners) {
this._addAllListeners(events);
this._boundedMessageListener =
this._boundedMessageListener ||
this._handleBackgroundMessage.bind(this);
chrome.runtime.onMessage.addListener(this._boundedMessageListener);
window.pptRecorderAddedControlListeners = true;
}
if (
!window.document.pptRecorderAddedControlListeners &&
chrome.runtime &&
chrome.runtime.onMessage
) {
window.document.pptRecorderAddedControlListeners = true;
}
if (this._isTopFrame) {
this._sendMessage({ control: ctrl.EVENT_RECORDER_STARTED });
this._sendMessage({
control: ctrl.GET_CURRENT_URL,
href: window.location.href
});
this._sendMessage({
control: ctrl.GET_VIEWPORT_SIZE,
coordinates: { width: window.innerWidth, height: window.innerHeight }
});
console.debug("Puppeteer Recorder in-page EventRecorder started");
}
}
_handleBackgroundMessage(msg) {
console.debug("content-script: message from background", msg);
if (msg && msg.action) {
switch (msg.action) {
case actions.TOGGLE_SCREENSHOT_MODE:
this._handleScreenshotMode(false);
break;
case actions.TOGGLE_SCREENSHOT_CLIPPED_MODE:
this._handleScreenshotMode(true);
break;
default:
}
}
}
_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 {
// poor man's way of detecting whether this script was injected by an actual extension, or is loaded for
// testing purposes
if (chrome.runtime && chrome.runtime.onMessage) {
chrome.runtime.sendMessage(msg);
} else {
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 {
this._sendMessage({
selector: this._getSelector(e),
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: EventRecorder._getCoordinates(e)
});
} catch (err) {
console.error(err);
}
}
_getEventLog() {
return this._eventLog;
}
_clearEventLog() {
this._eventLog = [];
}
_handleScreenshotMode(isClipped) {
this._disableClickRecording();
this._uiController = new UIController({ showSelector: isClipped });
this._screenShotMode = !this._screenShotMode;
document.body.style.cursor = "crosshair";
console.debug("screenshot mode:", this._screenShotMode);
if (this._screenShotMode) {
this._uiController.showSelector();
} else {
this._uiController.hideSelector();
}
this._uiController.on("click", event => {
this._screenShotMode = false;
document.body.style.cursor = DEFAULT_MOUSE_CURSOR;
this._sendMessage({ control: ctrl.GET_SCREENSHOT, value: event.clip });
this._enableClickRecording();
});
}
_disableClickRecording() {
this._isRecordingClicks = false;
}
_enableClickRecording() {
this._isRecordingClicks = true;
}
_getSelector(e) {
if (this._dataAttribute && e.target.getAttribute(this._dataAttribute)) {
return `[${this._dataAttribute}="${e.target.getAttribute(
this._dataAttribute
)}"]`;
}
if (e.target.id) {
return `#${e.target.id}`;
}
return finder(e.target, {
seedMinLength: 5,
optimizedMinLength: e.target.id ? 2 : 10,
attr: name => name === this._dataAttribute
});
}
static _getCoordinates(evt) {
const eventsWithCoordinates = {
mouseup: true,
mousedown: true,
mousemove: true,
mouseover: true
};
return eventsWithCoordinates[evt.type]
? { x: evt.clientX, y: evt.clientY }
: null;
}
}

View File

@@ -0,0 +1,124 @@
import EventEmitter from "events";
const BORDER_THICKNESS = 3;
const defaults = {
showSelector: false
};
class UIController extends EventEmitter {
constructor(options) {
options = Object.assign({}, defaults, options);
super();
this._overlay = null;
this._selector = null;
this._element = null;
this._dimensions = {};
this._showSelector = options.showSelector;
this._boundeMouseMove = this._mousemove.bind(this);
this._boundeMouseUp = this._mouseup.bind(this);
}
showSelector() {
console.debug("UIController:show");
if (!this._overlay) {
this._overlay = document.createElement("div");
this._overlay.className = "headlessRecorderOverlay";
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";
if (this._showSelector) {
this._selector = document.createElement("div");
this._selector.className = "headlessRecorderOutline";
this._selector.style.position = "fixed";
this._selector.style.border =
BORDER_THICKNESS + "px solid rgba(69,200,241,0.8)";
this._selector.style.borderRadius = "3px";
this._overlay.appendChild(this._selector);
}
}
if (!this._overlay.parentNode) {
document.body.appendChild(this._overlay);
document.body.addEventListener("mousemove", this._boundeMouseMove, false);
document.body.addEventListener("click", this._boundeMouseUp, false);
}
}
hideSelector() {
console.debug("UIController:hide");
if (this._overlay) {
document.body.removeChild(this._overlay);
}
this._overlay = this._selector = this._element = null;
this._dimensions = {};
}
_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 !== 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";
console.debug(
`top: ${this._selector.style.top}, left: ${this._selector.style.left}, width: ${this._selector.style.width}, height: ${this._selector.style.height}`
);
}
}
}
_mouseup(e) {
this._overlay.style.backgroundColor = "white";
setTimeout(() => {
this._overlay.style.backgroundColor = "none";
this._cleanup();
let clip = null;
if (this._selector) {
clip = {
x: this._selector.style.left,
y: this._selector.style.top,
width: this._selector.style.width,
height: this._selector.style.height
};
}
this.emit("click", { clip, raw: e });
}, 100);
}
_cleanup() {
document.body.removeEventListener(
"mousemove",
this._boundeMouseMove,
false
);
document.body.removeEventListener("mouseup", this._boundeMouseUp, false);
document.body.removeChild(this._overlay);
}
}
export default UIController;

View File

@@ -0,0 +1,45 @@
import UIController from "../UIController";
// 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();
});

View File

@@ -0,0 +1,69 @@
import puppeteer from 'puppeteer'
import { launchPuppeteerWithExtension } from '@/__e2e-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')
})
})

View File

@@ -0,0 +1,19 @@
<!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>

View File

@@ -0,0 +1,64 @@
<!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>

View File

@@ -0,0 +1,99 @@
import puppeteer from 'puppeteer'
import _ from 'lodash'
import { launchPuppeteerWithExtension } from '@/__e2e-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')
})
})

View File

@@ -0,0 +1,51 @@
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();
});
};

View File

@@ -1 +0,0 @@
console.log("Hello from the content-script");

View File

@@ -0,0 +1,3 @@
import EventRecorder from './EventRecorder'
window.eventRecorder = new EventRecorder()
window.eventRecorder.boot()

View File

@@ -1,4 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");

View File

@@ -2,7 +2,6 @@
"name": "Headless Recorder",
"version": "1.0.0",
"manifest_version": 2,
"homepage_url": "http://localhost:8080/",
"description": "A Chrome extension for recording browser interaction and generating Puppeteer & Playwright scripts",
"default_locale": "en",
"permissions": [
@@ -10,6 +9,7 @@
"webNavigation",
"activeTab",
"contextMenus",
"management",
"*://*/"
],
"icons" : {
@@ -34,6 +34,23 @@
},
"options_ui": {
"page": "options.html",
"browser_style": true
"browser_style": true,
"open_in_tab": true
},
"commands": {
"TOGGLE_SCREENSHOT_MODE": {
"suggested_key": {
"default": "Ctrl+Shift+A",
"mac": "Command+Shift+A"
},
"description": "Take screenshot"
},
"TOGGLE_SCREENSHOT_CLIPPED_MODE": {
"suggested_key": {
"default": "Ctrl+Shift+S",
"mac": "Command+Shift+S"
},
"description": "Take screenshot clipped"
}
}
}

View File

@@ -0,0 +1,6 @@
export default {
EVENT_RECORDER_STARTED: "EVENT_RECORDER_STARTED",
GET_VIEWPORT_SIZE: "GET_VIEWPORT_SIZE",
GET_CURRENT_URL: "GET_CURRENT_URL",
GET_SCREENSHOT: "GET_SCREENSHOT"
};

View File

@@ -0,0 +1,9 @@
export default {
TOGGLE_SCREENSHOT_MODE: "TOGGLE_SCREENSHOT_MODE",
TOGGLE_SCREENSHOT_CLIPPED_MODE: "TOGGLE_SCREENSHOT_CLIPPED_MODE",
START: "START",
STOP: "STOP",
CLEAN_UP: "CLEAN_UP",
PAUSE: "PAUSE",
UN_PAUSE: "UN_PAUSE"
};

View File

@@ -1,19 +1,328 @@
<template>
<hello-world />
<div class="options">
<div class="container">
<div class="header">
Headless Recorder Options
<small class="saving-badge text-muted" v-show="saving">
Saving...
</small>
</div>
<div class="content" v-if="!loading">
<div class="settings-block">
<h4 class="settings-block-title">
Code Recorder settings
</h4>
<div class="settings-block-main">
<div class="settings-group">
<label class="settings-label">custom data attribute</label>
<input
id="options-code-dataAttribute"
type="text"
v-model.trim="options.code.dataAttribute"
@change="save"
placeholder="your custom data-* attribute"
/>
<small
>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.</small
>
<small class="settings-warning"
> When data attribute is set, it will take precedence from
over other any selector (even ID)</small
>
</div>
<div class="settings-group">
<label class="settings-label">set key code</label>
<div class="settings-block">
<button
class="btn btn-sm btn-primary"
@click="listenForKeyCodePress"
>
{{
recordingKeyCodePress
? "Capturing"
: "Click to capture key code"
}}
</button>
<input
id="options-code-keyCode"
readonly
disabled
type="number"
v-model.number="options.code.keyCode"
placeholder="Key Code for input fields (ex. 9 = Tab)"
/>
</div>
<small
>What key will be used for capturing input changes. The value
here is the key code. This will not handle multiple keys.</small
>
</div>
</div>
</div>
<div class="settings-block">
<h4 class="settings-block-title">
Code Generator settings
</h4>
<div class="settings-block-main">
<div class="settings-group">
<label>
<input
id="options-code-wrapAsync"
type="checkbox"
v-model="options.code.wrapAsync"
@change="save"
/>
Wrap code in async function
</label>
</div>
<div class="settings-group">
<label>
<input
id="options-code-headless"
type="checkbox"
v-model="options.code.headless"
@change="save"
/>
Set <code>headless</code> in puppeteer launch options
</label>
</div>
<div class="settings-group">
<label>
<input
id="options-code-waitForNavigation"
type="checkbox"
v-model="options.code.waitForNavigation"
@change="save"
/>
Add <code>waitForNavigation</code> lines on navigation
</label>
</div>
<div class="settings-group">
<label>
<input
id="options-code-waitForSelectorOnClick"
type="checkbox"
v-model="options.code.waitForSelectorOnClick"
@change="save"
/>
Add <code>waitForSelector</code> lines before every
<code>page.click()</code>
</label>
</div>
<div class="settings-group">
<label>
<input
id="options-code-blankLinesBetweenBlocks"
type="checkbox"
v-model="options.code.blankLinesBetweenBlocks"
@change="save"
/>
Add blank lines between code blocks
</label>
</div>
<div class="settings-group">
<label>
<input
id="options-code-showPlaywrightFirst"
type="checkbox"
v-model="options.code.showPlaywrightFirst"
@change="save"
/>
Show Playwright tab first
</label>
</div>
</div>
</div>
<div class="settings-block">
<h4 class="settings-block-title">
Extension settings
</h4>
<div class="settings-block-main">
<div class="settings-group">
<label>
<input
id="options-telemetry"
type="checkbox"
v-model="options.extension.telemetry"
@change="save"
/>
Allow recording of usage telemetry
</label>
<br />
<small
>We only record clicks for basic product development, no website
content or input data. Data is never, ever shared with 3rd
parties.</small
>
</div>
</div>
</div>
</div>
<div class="footer">
sponsored by
<a href="https://checklyhq.com" target="_blank">
<img src="@/assets/images/text_racoon_logo.svg" alt="" />
</a>
</div>
</div>
</div>
</template>
<script>
import HelloWorld from "@/components/HelloWorld.vue";
import { defaults as code } from "@/services/CodeGenerator";
const defaults = {
code,
extension: {
telemetry: true
}
};
export default {
name: "App",
components: { HelloWorld }
data() {
return {
loading: true,
saving: false,
options: defaults,
recordingKeyCodePress: false
};
},
mounted() {
this.load();
},
methods: {
save() {
this.saving = true;
chrome.storage.local.set({ options: this.options }, () => {
console.debug("saved options");
setTimeout(() => {
this.saving = false;
}, 500);
});
},
load() {
chrome.storage.local.get("options", ({ options }) => {
if (options) {
console.debug("loaded options", JSON.stringify(options));
this.options = options;
}
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>
html {
width: 400px;
height: 400px;
<style lang="scss" scoped>
@import "../assets/styles/_variables.scss";
@import "../assets/styles/_mixins.scss";
.options {
height: 100%;
min-height: 580px;
background: $gray-lighter;
display: flex;
flex-direction: column;
width: 100%;
overflow: auto;
position: fixed;
left: 0;
top: 0;
.container {
padding: 0 2 * $spacer;
width: 550px;
margin: 0 auto;
.content {
background: white;
padding: 2 * $spacer;
border-radius: 4px;
min-height: 500px;
}
.footer {
@include footer();
background: $gray-lighter;
font-weight: normal;
justify-content: center;
img {
margin-left: 8px;
width: 80px;
vertical-align: middle;
}
}
.header {
@include header();
background: $gray-lighter;
justify-content: space-between;
}
.settings-block {
.settings-label {
display: block;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 500;
margin-bottom: $spacer;
}
.settings-warning {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: $pink;
margin: $spacer 0;
}
.settings-block-title {
margin: 0;
padding-bottom: $spacer;
border-bottom: 1px solid $gray-light;
}
.settings-block-main {
padding: $spacer 0;
margin-bottom: $spacer;
.settings-group {
margin-bottom: $spacer;
display: block;
}
}
input[type="text"],
input[type="number"] {
margin-bottom: 10px;
width: 100%;
border: 1px solid $gray-light;
padding-left: 15px;
height: 38px;
font-size: 14px;
border-radius: 10px;
-webkit-box-sizing: border-box;
}
input[type="number"] {
width: 50px;
}
}
}
}
</style>

View File

@@ -0,0 +1,86 @@
import { mount } from '@vue/test-utils'
import App from '../App'
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()
})
})
})

View File

@@ -0,0 +1,38 @@
// 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>
`;

View File

@@ -1,19 +1,340 @@
<template>
<hello-world />
<div id="headless-recorder" class="recorder">
<div class="header">
<a href="#" @click="goHome">
Headless recorder
<span class="text-muted"
><small>{{ version }}</small></span
>
</a>
<div class="left">
<div class="recording-badge" v-show="isRecording">
<span class="red-dot"></span>
{{ recordingBadgeText }}
</div>
<button @click="toggleShowHelp" class="header-button">
<img src="@/assets/images/help.svg" alt="help"/>
</button>
<button @click="openOptions" class="header-button">
<img src="@/assets/images/settings.svg" alt="settings"/>
</button>
</div>
</div>
<div class="main">
<div class="tabs" v-show="!showHelp">
<RecordingTab
:code="code"
:is-recording="isRecording"
:live-events="liveEvents"
v-show="!showResultsTab"
/>
<div class="recording-footer" v-show="!showResultsTab">
<button
class="btn btn-sm"
@click="toggleRecord"
:class="isRecording ? 'btn-danger' : 'btn-primary'"
>
{{ recordButtonText }}
</button>
<button
class="btn btn-sm btn-primary btn-outline-primary"
@click="togglePause"
v-show="isRecording"
>
{{ pauseButtonText }}
</button>
<a href="#" @click="showResultsTab = true" v-show="code">view code</a>
<checkly-badge v-if="!isRecording"></checkly-badge>
</div>
<ResultsTab
:puppeteer="code"
:playwright="codeForPlaywright"
:options="options"
v-if="showResultsTab"
v-on:update:tab="currentResultTab = $event"
/>
<div class="results-footer" v-show="showResultsTab">
<button class="btn btn-sm btn-primary" @click="restart" v-show="code">
Restart
</button>
<a href="#" @click.prevent="setCopying" v-show="code">{{
copyLinkText
}}</a>
</div>
</div>
<HelpTab v-show="showHelp"></HelpTab>
</div>
</div>
</template>
<script>
import HelloWorld from "@/components/HelloWorld.vue";
import { version } from "../../package.json";
import PuppeteerCodeGenerator from "@/services/PuppeteerCodeGenerator";
import PlaywrightCodeGenerator from "@/services/PlaywrightCodeGenerator";
import RecordingTab from "@/components/RecordingTab.vue";
import ResultsTab from "@/components/ResultsTab.vue";
import HelpTab from "@/components/HelpTab.vue";
import ChecklyBadge from "@/components/ChecklyBadge.vue";
import actions from "@/models/extension-ui-actions";
let bus;
export default {
name: "App",
components: { HelloWorld }
components: { ResultsTab, RecordingTab, HelpTab, ChecklyBadge },
data() {
return {
code: "",
codeForPlaywright: "",
options: {},
showResultsTab: false,
showHelp: false,
liveEvents: [],
recording: [],
isRecording: false,
isPaused: false,
isCopying: false,
bus: null,
version,
currentResultTab: null
};
},
mounted() {
this.loadState(() => {
this.trackPageView();
if (this.isRecording) {
console.debug("opened in recording state, fetching recording events");
chrome.storage.local.get(["recording", "options"], ({ recording }) => {
console.debug("loaded recording", recording);
this.liveEvents = recording;
});
}
if (!this.isRecording && this.code) {
this.showResultsTab = true;
}
});
bus = chrome.extension.connect({ name: "recordControls" });
},
methods: {
toggleRecord() {
if (this.isRecording) {
this.stop();
} else {
this.start();
}
this.isRecording = !this.isRecording;
this.storeState();
},
togglePause() {
if (this.isPaused) {
bus.postMessage({ action: actions.UN_PAUSE });
this.isPaused = false;
} else {
bus.postMessage({ action: actions.PAUSE });
this.isPaused = true;
}
this.storeState();
},
start() {
this.trackEvent("Start");
this.cleanUp();
console.debug("start recorder");
bus.postMessage({ action: actions.START });
},
stop() {
this.trackEvent("Stop");
console.debug("stop recorder");
bus.postMessage({ action: actions.STOP });
chrome.storage.local.get(
["recording", "options"],
({ recording, options }) => {
console.debug("loaded recording", recording);
console.debug("loaded options", options);
this.recording = recording;
const codeOptions = options ? options.code : {};
const codeGen = new PuppeteerCodeGenerator(codeOptions);
const codeGenPlaywright = new PlaywrightCodeGenerator(codeOptions);
this.code = codeGen.generate(this.recording);
this.codeForPlaywright = codeGenPlaywright.generate(this.recording);
this.showResultsTab = true;
this.storeState();
}
);
},
restart() {
this.cleanUp();
bus.postMessage({ action: actions.CLEAN_UP });
},
cleanUp() {
this.recording = this.liveEvents = [];
this.code = "";
this.codeForPlaywright = "";
this.showResultsTab = this.isRecording = this.isPaused = false;
this.storeState();
},
openOptions() {
this.trackEvent("Options");
if (chrome.runtime.openOptionsPage) {
chrome.runtime.openOptionsPage();
}
},
loadState(cb) {
chrome.storage.local.get(
["controls", "code", "options", "codeForPlaywright"],
({ controls, code, options, codeForPlaywright }) => {
if (controls) {
this.isRecording = controls.isRecording;
this.isPaused = controls.isPaused;
}
if (code) {
this.code = code;
}
if (codeForPlaywright) {
this.codeForPlaywright = codeForPlaywright;
}
if (options) {
this.options = options;
}
cb();
}
);
},
storeState() {
chrome.storage.local.set({
code: this.code,
codeForPlaywright: this.codeForPlaywright,
controls: {
isRecording: this.isRecording,
isPaused: this.isPaused
}
});
},
setCopying() {
this.trackEvent("Copy");
this.isCopying = true;
setTimeout(() => {
this.isCopying = false;
}, 1500);
},
goHome() {
this.showResultsTab = false;
this.showHelp = false;
},
toggleShowHelp() {
this.trackEvent("Help");
this.showHelp = !this.showHelp;
},
trackEvent(event) {
if (
this.options &&
this.options.extension &&
this.options.extension.telemetry
) {
if (window._gaq) window._gaq.push(["_trackEvent", event, "clicked"]);
}
},
trackPageView() {
if (
this.options &&
this.options.extension &&
this.options.extension.telemetry
) {
if (window._gaq) window._gaq.push(["_trackPageview"]);
}
},
getCodeForCopy() {
return this.currentResultTab === "puppeteer"
? this.code
: this.codeForPlaywright;
}
},
computed: {
recordingBadgeText() {
return this.isPaused ? "paused" : "recording";
},
recordButtonText() {
return this.isRecording ? "Stop" : "Record";
},
pauseButtonText() {
return this.isPaused ? "Resume" : "Pause";
},
copyLinkText() {
return this.isCopying ? "copied!" : "copy to clipboard";
}
}
};
</script>
<style>
html {
width: 400px;
height: 400px;
<style lang="scss" scoped>
@import "../assets/styles/_animations.scss";
@import "../assets/styles/_variables.scss";
@import "../assets/styles/_mixins.scss";
.recorder {
font-size: 14px;
.header {
@include header();
&-button {
color: $gray-dark;
background-color: transparent;
border: none;
cursor: pointer;
img {
width: 18px;
}
}
.left {
margin-left: auto;
display: flex;
justify-content: flex-start;
align-items: center;
.recording-badge {
color: $brand-danger;
.red-dot {
height: 9px;
width: 9px;
background-color: $brand-danger;
border-radius: 50%;
display: inline-block;
margin-right: 0.4rem;
vertical-align: middle;
position: relative;
}
}
.header-button {
margin-left: $spacer;
img {
vertical-align: middle;
}
}
}
}
.recording-footer {
@include footer();
img {
margin-left: 8px;
width: 80px;
vertical-align: middle;
}
}
.results-footer {
@include footer();
}
}
</style>

View File

@@ -0,0 +1,21 @@
import { shallowMount } from '@vue/test-utils'
import App from '../App'
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()
})
})

View File

@@ -0,0 +1,110 @@
// 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>
`;

View File

@@ -1,4 +1,10 @@
import { createApp } from "vue";
import App from "./App.vue";
import VueHighlightJS from "vue3-highlightjs";
import "highlight.js/styles/a11y-dark.css";
createApp(App).mount("#app");
import "@/assets/styles/main.scss";
createApp(App)
.use(VueHighlightJS)
.mount("#app");

25
src/services/Block.js Normal file
View File

@@ -0,0 +1,25 @@
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;
}
}

View File

@@ -0,0 +1,268 @@
import domEvents from "./dom-events-to-record";
import Block from "./Block";
import pptrActions from "./pptr-actions";
export const defaults = {
wrapAsync: true,
headless: true,
waitForNavigation: true,
waitForSelectorOnClick: true,
blankLinesBetweenBlocks: true,
dataAttribute: "",
showPlaywrightFirst: false,
keyCode: 9
};
export default class CodeGenerator {
constructor(options) {
this._options = Object.assign(defaults, options);
this._blocks = [];
this._frame = "page";
this._frameId = 0;
this._allFrames = {};
this._screenshotCounter = 1;
this._hasNavigation = false;
}
generate() {
throw new Error("Not implemented.");
}
_getHeader() {
console.debug(this._options);
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) {
console.debug(`generating code for ${events ? events.length : 0} 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 pptrActions.GOTO:
this._blocks.push(this._handleGoto(href, frameId));
break;
case pptrActions.VIEWPORT:
this._blocks.push(this._handleViewport(value.width, value.height));
break;
case pptrActions.NAVIGATION:
this._blocks.push(this._handleWaitForNavigation());
this._hasNavigation = true;
break;
case pptrActions.SCREENSHOT:
this._blocks.push(this._handleScreenshot(value));
break;
}
}
if (this._hasNavigation && this._options.waitForNavigation) {
console.debug("Adding navigationPromise declaration");
const block = new Block(this._frameId, {
type: pptrActions.NAVIGATION_PROMISE,
value: "const navigationPromise = page.waitForNavigation()"
});
this._blocks.unshift(block);
}
console.debug("post processing blocks:", this._blocks);
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: domEvents.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: domEvents.CLICK,
value: `await ${this._frame}.waitForSelector('${selector}')`
});
}
block.addLine({
type: domEvents.CLICK,
value: `await ${this._frame}.click('${selector}')`
});
return block;
}
_handleChange(selector, value) {
return new Block(this._frameId, {
type: domEvents.CHANGE,
value: `await ${this._frame}.select('${selector}', '${value}')`
});
}
_handleGoto(href) {
return new Block(this._frameId, {
type: pptrActions.GOTO,
value: `await ${this._frame}.goto('${href}')`
});
}
_handleViewport() {
throw new Error("Not implemented.");
}
_handleScreenshot(options) {
let block;
if (options && options.x && options.y && options.width && options.height) {
// remove the tailing 'px'
for (let prop in options) {
if (options.hasOwnProperty(prop) && options[prop].slice(-2) === "px") { // eslint-disable-line
options[prop] = options[prop].substring(0, options[prop].length - 2);
}
}
block = new Block(this._frameId, {
type: pptrActions.SCREENSHOT,
value: `await ${this._frame}.screenshot({ path: 'screenshot_${this._screenshotCounter}.png', clip: { x: ${options.x}, y: ${options.y}, width: ${options.width}, height: ${options.height} } })`
});
} else {
block = new Block(this._frameId, {
type: pptrActions.SCREENSHOT,
value: `await ${this._frame}.screenshot({ path: 'screenshot_${this._screenshotCounter}.png' })`
});
}
this._screenshotCounter++;
return block;
}
_handleWaitForNavigation() {
const block = new Block(this._frameId);
if (this._options.waitForNavigation) {
block.addLine({
type: pptrActions.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: pptrActions.FRAME_SET,
value: declaration
});
this._blocks[i].addLineToTop({
type: pptrActions.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, "\\'");
}
}

View File

@@ -0,0 +1,50 @@
import pptrActions from "./pptr-actions";
import Block from "./Block";
import CodeGenerator from "./CodeGenerator";
const importPlaywright = `const { chromium } = require('playwright');\n`;
const header = `const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()`;
const footer = `await browser.close()`;
const wrappedHeader = `(async () => {
${header}\n`;
const wrappedFooter = ` ${footer}
})()`;
export default class PlaywrightCodeGenerator extends CodeGenerator {
constructor(options) {
super(options);
this._header = header;
this._wrappedHeader = wrappedHeader;
this._footer = footer;
this._wrappedFooter = wrappedFooter;
}
generate(events) {
return (
importPlaywright +
this._getHeader() +
this._parseEvents(events) +
this._getFooter()
);
}
_handleViewport(width, height) {
return new Block(this._frameId, {
type: pptrActions.VIEWPORT,
value: `await ${this._frame}.setViewportSize({ width: ${width}, height: ${height} })`
});
}
_handleChange(selector, value) {
return new Block(this._frameId, {
type: pptrActions.CHANGE,
value: `await ${this._frame}.selectOption('${selector}', '${value}')`
});
}
}

View File

@@ -0,0 +1,43 @@
import pptrActions from "./pptr-actions";
import Block from "./Block";
import CodeGenerator from "./CodeGenerator";
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 CodeGenerator {
constructor(options) {
super(options);
this._header = header;
this._wrappedHeader = wrappedHeader;
this._footer = footer;
this._wrappedFooter = wrappedFooter;
}
generate(events) {
return (
importPuppeteer +
this._getHeader() +
this._parseEvents(events) +
this._getFooter()
);
}
_handleViewport(width, height) {
return new Block(this._frameId, {
type: pptrActions.VIEWPORT,
value: `await ${this._frame}.setViewport({ width: ${width}, height: ${height} })`
});
}
}

View File

@@ -0,0 +1,24 @@
import PlaywrightCodeGenerator from '../PlaywrightCodeGenerator'
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}')`
)
})
})

View File

@@ -0,0 +1,171 @@
import PuppeteerCodeGenerator from '../PuppeteerCodeGenerator'
import pptrActions from '../pptr-actions'
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: pptrActions.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: pptrActions.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: pptrActions.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\\\\')")
})
})

View File

@@ -0,0 +1,92 @@
export default {
CLICK: "click",
DBLCLICK: "dblclick",
CHANGE: "change",
KEYDOWN: "keydown",
SELECT: "select",
SUBMIT: "submit",
LOAD: "load",
UNLOAD: "unload"
};
// const events = [
// abort,
// afterprint,
// beforeprint,
// beforeunload,
// blur,
// canplay,
// canplaythrough,
// change,
// click,
// contextmenu,
// copy,
// cuechange,
// cut,
// dblclick,
// DOMContentLoaded,
// drag,
// dragend,
// dragenter,
// dragleave,
// dragover,
// dragstart,
// drop,
// durationchange,
// emptied,
// ended,
// error,
// focus,
// focusin,
// focusout,
// formchange,
// forminput,
// hashchange,
// input,
// invalid,
// keydown,
// keypress,
// keyup,
// load,
// loadeddata,
// loadedmetadata,
// loadstart,
// message,
// mousedown,
// mouseenter,
// mouseleave,
// mousemove,
// mouseout,
// mouseover,
// mouseup,
// mousewheel,
// offline,
// online,
// pagehide,
// pageshow,
// paste,
// pause,
// play,
// playing,
// popstate,
// progress,
// ratechange,
// readystatechange,
// redo,
// reset,
// resize,
// scroll,
// seeked,
// seeking,
// select,
// show,
// stalled,
// storage,
// submit,
// suspend,
// timeupdate,
// undo,
// unload,
// volumechange,
// waiting
// ];

View File

@@ -0,0 +1,9 @@
export default {
GOTO: "GOTO",
VIEWPORT: "VIEWPORT",
WAITFORSELECTOR: "WAITFORSELECTOR",
NAVIGATION: "NAVIGATION",
NAVIGATION_PROMISE: "NAVIGATION_PROMISE",
FRAME_SET: "FRAME_SET",
SCREENSHOT: "SCREENSHOT"
};

1028
tailwind.config.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ module.exports = {
},
contentScripts: {
entries: {
"content-script": ["src/content-scripts/content-script.js"]
"content-script": ["src/content-scripts/index.js"]
}
}
}