├── src ├── types │ ├── Runtime.ts │ └── Events.ts ├── styles │ ├── img │ │ └── parroteer-layered-waves-bg.png │ └── popup.scss ├── ui │ ├── popup.html │ ├── index.tsx │ ├── components │ │ ├── TextList.tsx │ │ ├── Loading.tsx │ │ ├── CopyButton.tsx │ │ ├── ExportButton.tsx │ │ ├── WrongTab.tsx │ │ └── NavButtons.tsx │ ├── testsView │ │ └── TestsView.tsx │ ├── pickerView │ │ └── PickerView.tsx │ ├── recorderView │ │ └── RecorderView.tsx │ └── App.tsx └── app │ ├── modules │ ├── textExporter.ts │ ├── mutationObserver.ts │ ├── elementPicker.ts │ ├── getSelector.ts │ ├── generateTests.ts │ └── eventListeners.ts │ ├── content.ts │ └── background.ts ├── dist ├── icons │ ├── parrot_128.png │ ├── parrot_16.png │ └── parrot_48.png ├── 7e34fe7193f0fbaccb16.png └── manifest.json ├── jest.config.js ├── tsconfig.json ├── LICENSE.md ├── .eslintrc.json ├── webpack.config.js ├── package.json ├── .gitignore ├── __tests__ └── generateTests.test.ts └── README.md /src/types/Runtime.ts: -------------------------------------------------------------------------------- 1 | export interface RuntimeMessage { 2 | type: string, 3 | payload?: unknown 4 | } -------------------------------------------------------------------------------- /dist/icons/parrot_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/parroteer/HEAD/dist/icons/parrot_128.png -------------------------------------------------------------------------------- /dist/icons/parrot_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/parroteer/HEAD/dist/icons/parrot_16.png -------------------------------------------------------------------------------- /dist/icons/parrot_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/parroteer/HEAD/dist/icons/parrot_48.png -------------------------------------------------------------------------------- /dist/7e34fe7193f0fbaccb16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/parroteer/HEAD/dist/7e34fe7193f0fbaccb16.png -------------------------------------------------------------------------------- /src/styles/img/parroteer-layered-waves-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/parroteer/HEAD/src/styles/img/parroteer-layered-waves-bg.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | }; -------------------------------------------------------------------------------- /src/ui/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Parroteer 6 | 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /src/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | 6 | console.log('Running popup app'); 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); -------------------------------------------------------------------------------- /src/ui/components/TextList.tsx: -------------------------------------------------------------------------------- 1 | interface TextProps { 2 | children?: JSX.Element | JSX.Element[] 3 | } 4 | 5 | const TextList = ({ children }: TextProps) => { 6 | // TODO: Add styling and scrolling 7 | return ( 8 | 11 | ); 12 | }; 13 | 14 | export default TextList; 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "target": "es6", 5 | "module": "es6", 6 | "jsx": "react-jsx", 7 | "noImplicitAny": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "node" 10 | }, 11 | "exclude": [ 12 | "node_modules", 13 | ], 14 | "include": [ 15 | "src/**/*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | const Loading = () => { 2 | return ( 3 |
4 | parrot 5 |

Loading...

6 |
7 |
8 |
9 | 10 |
11 | ); 12 | }; 13 | 14 | export default Loading; -------------------------------------------------------------------------------- /src/ui/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | interface CopyButtonProps { 2 | text: string 3 | } 4 | 5 | export default function CopyButton({ text }: CopyButtonProps) { 6 | const handleClick = () => navigator.clipboard.writeText(text); 7 | 8 | return ( 9 | 12 | ); 13 | } -------------------------------------------------------------------------------- /src/app/modules/textExporter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Prompts the user to save a provided string of text under the given (suggested) file name 3 | * @param type The mimetype of the file, e.g. `text/javascript`. Default 'javascript' 4 | */ 5 | export default function exportToFile(text: string, filename: string, type = 'javascript') { 6 | chrome.downloads.download({ 7 | filename: filename, 8 | url: `data:text/${type};charset=utf-8,${encodeURIComponent(text)}`, 9 | saveAs: true 10 | }); 11 | } -------------------------------------------------------------------------------- /src/ui/components/ExportButton.tsx: -------------------------------------------------------------------------------- 1 | import exportToFile from '../../app/modules/textExporter'; 2 | 3 | interface ExportButtonProps { 4 | text: string 5 | } 6 | 7 | export default function ExportButton({ text }: ExportButtonProps) { 8 | const handleExport = () => { 9 | exportToFile(text, 'test.js'); 10 | }; 11 | 12 | return ( 13 | <> 14 | 17 |

Export test file

18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Parroteer", 3 | "version": "1.0.0", 4 | "manifest_version": 3, 5 | "icons": { 6 | "16": "./icons/parrot_16.png", 7 | "48": "./icons/parrot_48.png", 8 | "128": "./icons/parrot_128.png" 9 | }, 10 | "permissions": ["downloads"], 11 | "description": "No-code test automation solution for end-to-end testing", 12 | "homepage_url": "https://github.com/oslabs-beta/parroteer", 13 | "action": { 14 | "default_title": "Parroteer", 15 | "default_popup": "./popup.html" 16 | }, 17 | "background": { 18 | "service_worker": "./background.js" 19 | }, 20 | "content_scripts": [ 21 | { 22 | "matches": [ 23 | "https://*/*", "http://*/*" 24 | ], 25 | "js": [ 26 | "./content.js" 27 | ] 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /src/types/Events.ts: -------------------------------------------------------------------------------- 1 | export interface StoredEvent { 2 | type: 'input' | 'mutation' | 'picked-element', 3 | parroteerId: ParroteerId, 4 | selector: CssSelector, 5 | displaySelector: CssSelector, 6 | timestamp?: number 7 | } 8 | 9 | export interface MutationEvent extends Partial, StoredEvent { 10 | type: 'mutation' 11 | } 12 | 13 | export interface UserInputEvent extends StoredEvent { 14 | type: 'input', 15 | eventType: string, 16 | key?: string, 17 | code?: string, 18 | shift?: boolean 19 | } 20 | 21 | export interface PickedElementEvent extends StoredEvent { 22 | type: 'picked-element' 23 | } 24 | 25 | export type CssSelector = string; 26 | 27 | export type ParroteerId = string; 28 | 29 | export interface ElementState { 30 | class?: string, 31 | textContent?: string, 32 | value?: string 33 | } 34 | 35 | export type RecordingState = 'pre-recording' | 'recording' | 'off'; 36 | 37 | export type EventLog = (PickedElementEvent | UserInputEvent | MutationEvent)[]; -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/ui/components/WrongTab.tsx: -------------------------------------------------------------------------------- 1 | interface WrongTabProps { 2 | recordingTab: number, 3 | setOnCorrectTab: (str: boolean) => void, 4 | } 5 | 6 | const WrongTab = (props: WrongTabProps) => { 7 | const {recordingTab, setOnCorrectTab} = props; 8 | 9 | const handleEndSession = () => { 10 | chrome.runtime.sendMessage({ type: 'restart-recording' }); 11 | setOnCorrectTab(true); 12 | }; 13 | 14 | const handleGoToRecordingTab = () => { 15 | chrome.tabs.update(recordingTab, {active: true}); 16 | }; 17 | 18 | return ( 19 |
20 | parrot 21 |
22 |

Oops!

23 |

You are recording on a different tab.

24 |

Please navigate back to that tab to continue your recording session.

25 |
26 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | export default WrongTab; -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/test", "**/__tests__"], 4 | "env": { 5 | "node": true, 6 | "browser": true, 7 | "es2022": true 8 | }, 9 | "plugins": ["react", "react-hooks", "@typescript-eslint"], 10 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:react-hooks/recommended", "plugin:@typescript-eslint/recommended"], 11 | "parserOptions": { 12 | "sourceType": "module", 13 | "ecmaFeatures": { 14 | "jsx": true 15 | } 16 | }, 17 | "rules": { 18 | "indent": ["warn", 2, { 19 | "ignoreComments": true, 20 | "SwitchCase": 1, 21 | "MemberExpression": 0, 22 | "flatTernaryExpressions": true 23 | }], 24 | "no-unused-vars": "off", 25 | "@typescript-eslint/no-unused-vars": ["warn", { 26 | "vars": "local", 27 | "args": "none" 28 | }], 29 | "no-case-declarations": "off", 30 | "prefer-const": "warn", 31 | "quotes": ["warn", "single"], 32 | "react/prop-types": "warn", 33 | "semi": ["warn", "always"], 34 | "space-infix-ops": "warn", 35 | "no-trailing-spaces": ["warn", { 36 | "skipBlankLines": true, 37 | "ignoreComments": true 38 | }], 39 | "react/jsx-uses-react": "off", 40 | "react/react-in-jsx-scope": "off" 41 | }, 42 | "settings": { 43 | "react": { "version": "detect"} 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ui/testsView/TestsView.tsx: -------------------------------------------------------------------------------- 1 | import CodeMirror from '@uiw/react-codemirror'; 2 | import { useState, useEffect } from 'react'; 3 | import { javascript } from '@codemirror/lang-javascript'; 4 | import { dracula } from '@uiw/codemirror-theme-dracula'; 5 | import CopyButton from '../components/CopyButton'; 6 | import ExportButton from '../components/ExportButton'; 7 | 8 | // interface testProps { 9 | // tests: string, 10 | // setTests?: (string: string) => void 11 | // } 12 | 13 | const TestsView = () => { 14 | // const {tests} = props; 15 | const [newTests, setNewTests] = useState(''); 16 | const [isLoaded, setisLoaded] = useState(false); 17 | useEffect(() => { 18 | chrome.runtime.sendMessage({ type: 'get-tests' }).then((res) => { 19 | setNewTests(res); 20 | }); 21 | setisLoaded(true); 22 | }, []); 23 | 24 | return ( isLoaded ? 25 |
26 |
27 | 28 |
29 | 37 | 38 |
:
); 39 | }; 40 | 41 | export default TestsView; -------------------------------------------------------------------------------- /src/ui/pickerView/PickerView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TextList from '../components/TextList'; 3 | import { RecordingState, EventLog } from '../../types/Events'; 4 | 5 | interface PickerProps { 6 | setRecordingState: (str: RecordingState) => void 7 | events: EventLog; 8 | } 9 | 10 | const PickerView = (props: PickerProps) => { 11 | const {setRecordingState, events} = props; 12 | 13 | const onPickElClick = () => { 14 | setRecordingState('pre-recording'); 15 | chrome.runtime.sendMessage({ type: 'begin-pick-elements' }); 16 | chrome.action.setBadgeText({text: 'PICK'}); 17 | chrome.action.setBadgeBackgroundColor({color: '#F0AD2B'}); 18 | }; 19 | 20 | const textItems = events.map((event, i) => { 21 | const {type, selector } = event; 22 | 23 | if (type === 'picked-element') { 24 | // ex: Element picked selector 25 | const displayText = `Element picked ${selector}`; 26 | return
  • {displayText}
  • ; 27 | } 28 | }); 29 | 30 | return ( 31 |
    32 |
    33 | 34 |

    Pick elements

    35 |
    36 | 37 | { textItems } 38 | 39 |
    40 | ); 41 | }; 42 | 43 | export default PickerView; 44 | 45 | // TODO: Disable back button and only enable next button if elements have been selected -------------------------------------------------------------------------------- /src/app/modules/mutationObserver.ts: -------------------------------------------------------------------------------- 1 | // mutation observer 2 | // Select the node that will be observed for mutations 3 | export const targetNode = document.querySelector('body'); 4 | // Options for the observer (which mutations to observe) 5 | export const config = { attributes: true, childList: true, subtree: true, characterData: true, attributeOldValue: true, characterDataOldValue: true }; 6 | 7 | // Callback function to execute when mutations are observed 8 | const callback: MutationCallback = function(mutationList, observer) { 9 | // Use traditional 'for loops' for IE 11npm 10 | 11 | // will mutationlist contain all mutations or only per recording period? 12 | // stretch - users input attribute to select and change 13 | for(const mutation of mutationList) { 14 | 15 | if (mutation.type === 'childList') { 16 | console.log('A child node has been added or removed.'); 17 | console.log(`%c${'CHILDIST MUTATION'}`, 'background-color: red'); 18 | console.log(mutation); 19 | } 20 | else if (mutation.type === 'attributes' && mutation.attributeName !== 'style') { 21 | 22 | console.log('The ' + mutation.attributeName + ' attribute was modified.'); 23 | console.log(`%c${'ATTRIBUTE MUTATION '}`, 'background-color: aqua'); 24 | console.log(mutation); 25 | } else if (mutation.type === 'characterData'){ 26 | console.log(`%c${'characterData'}`, 'background-color: maroon'); 27 | console.log(mutation); 28 | } 29 | } 30 | } ; 31 | 32 | 33 | 34 | // Create an observer instance linked to the callback function 35 | const observer = new MutationObserver(callback); 36 | 37 | export default observer; 38 | // Start observing the target node for configured mutations 39 | // observer.observe(targetNode, config); 40 | 41 | // Later, you can stop observing 42 | // observer.disconnect(); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | /** @type {import('webpack').Configuration} */ 6 | module.exports = { 7 | mode: process.env.NODE_ENV, 8 | devtool: 'inline-source-map', 9 | entry: { 10 | content: './src/app/content.ts', 11 | background: './src/app/background.ts', 12 | popup: './src/ui/index.tsx', 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: '[name].js' 17 | }, 18 | resolve: { 19 | extensions: ['.ts', '.tsx', '.js'] 20 | }, 21 | watchOptions: { 22 | ignored: /node_modules/ 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.tsx?$/, 28 | exclude: /node_modules/, 29 | loader: 'ts-loader' 30 | }, 31 | { 32 | test: /\.css$/, 33 | exclude: /node_modules/, 34 | use: ['style-loader', 'css-loader'] 35 | }, 36 | { 37 | test: /\.scss$/, 38 | exclude: /node_modules/, 39 | use: ['style-loader', 'css-loader', 'sass-loader'] 40 | } 41 | ] 42 | }, 43 | devServer: { 44 | host: 'localhost', 45 | port: 8080, 46 | hot: true, 47 | historyApiFallback: true, 48 | static: { 49 | directory: path.resolve(__dirname, 'dist'), 50 | publicPath: '/' 51 | }, 52 | // headers: { 'Access-Control-Allow-Origin': '*' }, 53 | proxy: { 54 | '/api/**': { 55 | target: 'http://localhost:3000/', 56 | secure: false 57 | }, 58 | '/assets/**': { 59 | target: 'http://localhost:3000/', 60 | secure: false 61 | } 62 | } 63 | }, 64 | plugins: [ 65 | new HtmlWebpackPlugin({ 66 | template: './src/ui/popup.html', 67 | filename: 'popup.html', 68 | chunks: ['popup'] 69 | }) 70 | ], 71 | }; 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parroteer", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot --progress --color", 9 | "build": "NODE_ENV=production webpack", 10 | "build-watch": "NODE_ENV=development webpack --watch" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "BSD-3-Clause", 15 | "devDependencies": { 16 | "@testing-library/react": "^12.1.5", 17 | "@types/chrome": "^0.0.193", 18 | "@types/dedent": "^0.7.0", 19 | "@types/jest": "^28.1.6", 20 | "@types/react": "^17.0.47", 21 | "@types/react-dom": "^17.0.17", 22 | "@types/uuid": "^8.3.4", 23 | "@typescript-eslint/eslint-plugin": "^5.30.0", 24 | "@typescript-eslint/parser": "^5.30.0", 25 | "css-loader": "^6.7.1", 26 | "eslint-plugin-react": "^7.30.1", 27 | "eslint-plugin-react-hooks": "^4.6.0", 28 | "html-webpack-plugin": "^5.5.0", 29 | "jest": "^28.1.3", 30 | "jest-environment-jsdom": "^28.1.3", 31 | "jsdom": "^20.0.0", 32 | "sass": "^1.53.0", 33 | "sass-loader": "^13.0.2", 34 | "style-loader": "^3.3.1", 35 | "ts-jest": "^28.0.6", 36 | "ts-loader": "^9.3.1", 37 | "typescript": "^4.7.4", 38 | "webpack": "^5.73.0", 39 | "webpack-cli": "^4.10.0", 40 | "webpack-dev-server": "^4.9.2", 41 | "webpack-hot-middleware": "^2.25.1" 42 | }, 43 | "dependencies": { 44 | "@codemirror/lang-javascript": "^6.0.1", 45 | "@uiw/codemirror-theme-dracula": "^4.11.4", 46 | "@uiw/react-codemirror": "^4.11.4", 47 | "codemirror": "^6.0.1", 48 | "cross-env": "^7.0.3", 49 | "endent": "^2.1.0", 50 | "eslint": "^8.18.0", 51 | "install": "^0.13.0", 52 | "npm": "^8.14.0", 53 | "react": "^17.0.2", 54 | "react-dom": "^17.0.2", 55 | "react-router-dom": "^6.3.0", 56 | "uuid": "^8.3.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/content.ts: -------------------------------------------------------------------------------- 1 | // This script has access to the DOM 2 | 3 | import * as listeners from './modules/eventListeners'; 4 | import {diffElementStates} from './modules/eventListeners'; 5 | // import observer, {targetNode, config} from './modules/mutationObserver'; 6 | import { enableHighlight, disableHighlight } from './modules/elementPicker'; 7 | import { RuntimeMessage } from '../types/Runtime'; 8 | import { ElementState, ParroteerId, RecordingState } from '../types/Events'; 9 | 10 | console.log('Running content script (see chrome devtools)'); 11 | 12 | // Listen for messages from background script 13 | chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => { 14 | switch (message.type) { 15 | case 'add-listeners': { 16 | const {idsToClear, recordingState } = message.payload as {idsToClear: string[], recordingState: RecordingState }; 17 | listeners.startEventListeners(recordingState); 18 | if (recordingState === 'pre-recording') enableHighlight(); 19 | else { 20 | disableHighlight(); 21 | if (recordingState === 'off') listeners.stopEventListeners(); 22 | } 23 | if (idsToClear) { 24 | idsToClear.forEach((id: ParroteerId) => { 25 | const el = document.querySelector(`[data-parroteer-id="${id}"]`); 26 | if (!(el instanceof HTMLElement)) return; 27 | delete el.dataset.parroteerID; 28 | });} 29 | break; 30 | } 31 | case 'get-element-states': { 32 | // Iterate over all selectures in payload 33 | // Look up those elements in the DOM and get their state 34 | const payload = message.payload as string[]; 35 | const elementStates: { [key: ParroteerId]: ElementState } = {}; 36 | for (const parroterId of payload) { 37 | elementStates[parroterId] = listeners.getCurrState(parroterId); 38 | } 39 | 40 | // Send those states back to the background 41 | sendResponse(elementStates); 42 | break; 43 | } 44 | case 'watch-element': { 45 | const selector = message.payload as string; 46 | const elInfo = listeners.watchElement(selector); 47 | sendResponse(elInfo); 48 | break; 49 | } 50 | case 'final-diff': { 51 | sendResponse(diffElementStates()); 52 | } 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Config files 12 | *config.json 13 | !tsconfig.json 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | .env.test 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist/*.js 90 | dist/*.html 91 | dist/*.LICENSE.txt 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # Serverless directories 103 | .serverless/ 104 | 105 | # FuseBox cache 106 | .fusebox/ 107 | 108 | # DynamoDB Local files 109 | .dynamodb/ 110 | 111 | # TernJS port file 112 | .tern-port 113 | -------------------------------------------------------------------------------- /src/ui/components/NavButtons.tsx: -------------------------------------------------------------------------------- 1 | 2 | import {Link, useNavigate, useLocation} from 'react-router-dom'; 3 | import { RecordingState, EventLog } from '../../types/Events'; 4 | 5 | interface NavButtonsProps { 6 | recordingState: RecordingState 7 | restartSwitch: boolean 8 | setRestartSwitch: (bool: boolean) => void 9 | handleRestart: () => void 10 | onEndClick: () => void 11 | } 12 | 13 | type NavChoice = 'PICK' | 'RECORD' | 'TESTS'; 14 | type NavRoute = [path: string, text: string]; 15 | 16 | const Nav: Record = { 17 | PICK: ['/pickerView', 'Pick\nElements'], 18 | RECORD: ['/recorderView', 'Record'], 19 | TESTS: ['/testsView', 'View Tests'] 20 | }; 21 | 22 | const NavButtons = ({ recordingState, restartSwitch, setRestartSwitch, handleRestart, onEndClick }: NavButtonsProps) => { 23 | const navigate = useNavigate(); 24 | const location = useLocation(); 25 | let backPath = '', backText = 'Back'; 26 | let nextPath = '', nextText = 'Next'; 27 | 28 | // Set next/back buttons and paths depending on current location 29 | switch (location.pathname) { 30 | case '/pickerView': 31 | [nextPath, nextText] = Nav.RECORD; 32 | break; 33 | case '/recorderView': 34 | [backPath, backText] = Nav.PICK; 35 | [nextPath, nextText] = Nav.TESTS; 36 | 37 | // if (recordingState === 'recording') [nextPath, nextText] = Nav.TESTS; 38 | // TODO: Need a better way to detect if a recording has been started; 39 | // user should be able to go forward after starting a recording - including if they've now stopped it, 40 | // but maybe shouldn't if they haven't started one yet in the current session 41 | break; 42 | case '/testsView': 43 | [backPath, backText] = Nav.RECORD; 44 | [nextPath] = Nav.PICK; 45 | nextText = 'Restart'; 46 | break; 47 | } 48 | 49 | const handleNext = () => { 50 | if (nextText === 'Restart') return handleRestart(); 51 | else if (nextText === 'View Tests') { 52 | navigate(nextPath); 53 | return onEndClick(); 54 | } 55 | else return navigate(nextPath); 56 | }; 57 | 58 | const nextIcon = ( 59 | nextText === 'Restart' ? restart_alt 60 | : navigate_next 61 | ); 62 | let nextClass = 'next'; 63 | if (nextText === 'Restart') nextClass += ' restart'; 64 | 65 | 66 | return ( 67 | 77 | ); 78 | }; 79 | 80 | export default NavButtons; 81 | -------------------------------------------------------------------------------- /src/app/modules/elementPicker.ts: -------------------------------------------------------------------------------- 1 | import getRelativeSelector from './getSelector'; 2 | 3 | // Hover over an element 4 | // Add a highlight element / move highlight element on the page and copy the size and position of the element that was hovered 5 | // Add background / border to highlight element 6 | let highlightElement: HTMLElement; 7 | let lilPopUp: HTMLElement; 8 | /* 9 |
    <-- highlighter 10 | <-- lilPopUp (Selector display) 11 |
    12 | */ 13 | 14 | /** 15 | * Enable element highlighter 16 | */ 17 | export function enableHighlight() { 18 | if (highlightElement) highlightElement.remove(); 19 | 20 | highlightElement = document.createElement('div'); 21 | lilPopUp = document.createElement('span'); 22 | // highlightElement.id = 'highlighter'; 23 | 24 | // Style highlighter 25 | Object.assign(highlightElement.style, { 26 | backgroundColor: '#F8D13C55', 27 | zIndex: '1000000', 28 | position: 'fixed', 29 | pointerEvents: 'none', 30 | border: '1.8px dashed #ca8c11' 31 | }); 32 | 33 | // Style popup 34 | Object.assign(lilPopUp.style, { 35 | position: 'absolute', 36 | bottom: 'calc(100% + 2px)', 37 | left: '50%', 38 | backgroundColor: '#f6d867', 39 | fontWeight: 'bold', 40 | fontFamily: 'monospace', 41 | color: '#ca8c11', 42 | width: 'max-content', 43 | borderRadius: '0.5em', 44 | padding: '0.1em 0.5em', 45 | fontSize: '1rem', 46 | transform: 'translateX(-50%)' 47 | }); 48 | 49 | // Append highlighter to body 50 | document.body.appendChild(highlightElement); 51 | highlightElement.appendChild(lilPopUp); 52 | 53 | // Add event listers 54 | // (Remove old event listeners if present) 55 | document.removeEventListener('mouseover', hoverListener); 56 | document.addEventListener('mouseover', hoverListener); 57 | // TODO: Add mousemove and scroll events to track mouse position and update highlighted element based on calculated mouse position 58 | /* document.removeEventListener('scroll', hoverListener); 59 | document.addEventListener('scroll', hoverListener); */ 60 | } 61 | 62 | /** 63 | * Disable element highlighter 64 | */ 65 | export function disableHighlight() { 66 | // document.removeEventListener('scroll', hoverListener); 67 | document.removeEventListener('mouseover', hoverListener); 68 | highlightElement.remove(); 69 | // TODO: Remove highlighter from DOM 70 | } 71 | 72 | /** 73 | * Determines the position of an element that is hovered over, then assigns the highlight element to the same position 74 | */ 75 | function hoverListener(this: Document, event: MouseEvent) { 76 | const target = event.target as HTMLElement; 77 | // console.log(target); 78 | 79 | // Set height, width, left, top of highlight element 80 | // Adjust properties to account for 2px border 81 | const {height, width, left, top} = target.getBoundingClientRect(); 82 | Object.assign(highlightElement.style, { 83 | height: `${height + 4}px`, 84 | width: `${width + 4}px`, 85 | left: `${left - 2}px`, 86 | top: `${top - 2}px` 87 | }); 88 | 89 | // Call get selector and put into element 90 | lilPopUp.textContent = getRelativeSelector(target); 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/app/modules/getSelector.ts: -------------------------------------------------------------------------------- 1 | import { CssSelector } from '../../types/Events'; 2 | 3 | /** 4 | * Builds a relative selector for a given element, based on properties of itself and its parents 5 | * @param target The target element 6 | * @param height How many parents above the element to check. `height` of 0 means only check given element. Default 3 7 | */ 8 | export default function getRelativeSelector(target: EventTarget, height = 3) { // EventTarget could be: Element, Document, Window 9 | if (!(target instanceof HTMLElement) || target instanceof HTMLHtmlElement) return; 10 | const selectors: CssSelector[] = []; 11 | 12 | let currElement = target; 13 | 14 | for (let i = 0; i <= height; i++) { 15 | let elSelector = getSimpleSelector(currElement); 16 | 17 | // Check if element has any siblings with the same selector 18 | // If so, add 'nth-of-type' pseudoselector 19 | elSelector += getNthTypeSelector(currElement) || ''; 20 | 21 | selectors.unshift(elSelector); 22 | 23 | // If element had an id or we've reached the body, we can't go up any higher 24 | if (currElement.id || currElement.tagName === 'BODY') break; 25 | 26 | // On each iteration, grab the parent node and tell Typescript its going to be an Element, not Document or Window 27 | currElement = currElement.parentElement; 28 | } 29 | 30 | return selectors.join(' > '); 31 | } 32 | 33 | /** 34 | * Gets a simple selector for a single element, using the most specific identifier available 35 | * In order of preference: id > class > tag 36 | */ 37 | export function getSimpleSelector(element: Element): CssSelector { 38 | return ( 39 | element.id ? '#' + element.id 40 | : element.classList?.value ? '.' + element.classList.value.trim().replace(/\s+/g, '.') 41 | : element.tagName.toLowerCase() 42 | ); 43 | } 44 | 45 | /** 46 | * If an element has sibling elements which would match it's simple selector, 47 | * generates a pseudoselector in the form of `:nth-of-type(n)`. 48 | * If element has no siblings, returns null 49 | */ 50 | export function getNthTypeSelector(element: Element): CssSelector | null { 51 | const elSelector = getSimpleSelector(element); 52 | const selMatcher = new RegExp(elSelector); 53 | 54 | let childIndex = 0, hasTwin = false; 55 | for (const sibling of element.parentElement.children) { 56 | if (sibling === element) break; 57 | 58 | const siblingSelector = getFullSelector(sibling); 59 | if (siblingSelector.match(selMatcher)) { 60 | childIndex++; 61 | hasTwin = true; 62 | } 63 | } 64 | return (hasTwin ? `:nth-of-type(${childIndex + 1})` : null); 65 | } 66 | 67 | /** 68 | * Gets as full of a selector as possible for a given element. Does not include pseudoselectors 69 | * *e.g.* `div#someId.class1.class2` 70 | */ 71 | export function getFullSelector(element: Element): CssSelector { 72 | let selector = getTagSelector(element); 73 | if (element.id) selector += getIdSelector(element); 74 | if (element.classList?.value) selector += getClassSelector(element); 75 | return selector; 76 | } 77 | 78 | function getIdSelector(element: Element): CssSelector { 79 | return '#' + element.id; 80 | } 81 | 82 | function getClassSelector(element: Element): CssSelector { 83 | return '.' + element.classList.value.trim().replace(/\s+/g, '.'); 84 | } 85 | 86 | function getTagSelector(element: Element): CssSelector { 87 | return element.tagName.toLowerCase(); 88 | } -------------------------------------------------------------------------------- /src/ui/recorderView/RecorderView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TextList from '../components/TextList'; 3 | import { EventLog, RecordingState, MutationEvent } from '../../types/Events'; 4 | 5 | 6 | interface RecProps { 7 | recordingState: string, 8 | setRecordingState: (str: RecordingState) => void 9 | events: EventLog 10 | onEndClick: () => void 11 | } 12 | 13 | 14 | const RecorderView = (props: RecProps) => { 15 | const {recordingState, setRecordingState, events, onEndClick} = props; 16 | // const [tests, setTests] = useState(''); 17 | let curButtons; 18 | 19 | 20 | const onRecordClick = () => { 21 | setRecordingState('recording'); 22 | chrome.runtime.sendMessage({ type: 'begin-recording' }); 23 | chrome.action.setBadgeText({text: 'REC'}); 24 | chrome.action.setBadgeBackgroundColor({color: '#ff401b'}); 25 | }; 26 | 27 | const onPauseClick = () => { 28 | setRecordingState('pre-recording'); 29 | chrome.runtime.sendMessage({ type: 'pause-recording' }); 30 | chrome.action.setBadgeText({text: 'PICK'}); 31 | chrome.action.setBadgeBackgroundColor({color: '#ffd700'}); 32 | }; 33 | 34 | const onResumeClick = () => { 35 | setRecordingState('recording'); 36 | chrome.runtime.sendMessage({ type: 'begin-recording' }); 37 | chrome.action.setBadgeText({text: 'REC'}); 38 | chrome.action.setBadgeBackgroundColor({color: '#ff401b'}); 39 | }; 40 | 41 | 42 | const buttons = { 43 | record: , 44 | pause: , 45 | Resume: , 46 | end: 47 | }; 48 | 49 | // set the buttons that show up in recorder tab 50 | if (recordingState === 'recording') { 51 | curButtons = buttons.pause; 52 | } else { 53 | curButtons = buttons.record; 54 | } 55 | 56 | const textItems = events.map((event, i) => { 57 | const {type, selector } = event; 58 | 59 | if (type === 'input') { 60 | // ex: Pressed A key on div#id.class 61 | const {eventType, key} = event; 62 | const action = ( 63 | eventType === 'click' ? 'Clicked ' 64 | : eventType === 'keydown' ? `Pressed ${key} key on ` 65 | : 'Unknown Event on ' 66 | ); 67 | const displayText = action + selector; 68 | return
  • {displayText}
  • ; 69 | } else if (type === 'mutation') { 70 | // { pID: '34tgds', textContent: 'hello', class: 'newclass' } 71 | // ex: Property on element changed to 72 | const listItems = []; 73 | for (const _key in event) { 74 | let mutationCount = 0; 75 | const key = _key as keyof MutationEvent; 76 | if (['textContent', 'value', 'class'].includes(key)) { 77 | mutationCount++; 78 | listItems.push(
  • "{key}" on {selector} changed to {event[key]}
  • ); 79 | } 80 | } 81 | return (<>{listItems}); 82 | } else { 83 | return null; 84 | } 85 | }); 86 | 87 | return ( 88 |
    89 |
    90 | {curButtons} 91 | {recordingState === 'off' ? null : buttons.end} 92 |

    Start/stop recording

    93 |
    94 | 95 | { textItems } 96 | 97 |
    98 | ); 99 | }; 100 | 101 | export default RecorderView; 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/ui/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import '../styles/popup.scss'; 3 | import NavButtons from './components/NavButtons'; 4 | import Loading from './components/Loading'; 5 | import {Routes, Route, useNavigate} from 'react-router-dom'; 6 | import PickerView from './pickerView/PickerView'; 7 | import RecorderView from './recorderView/RecorderView'; 8 | import TestsView from './testsView/TestsView'; 9 | import WrongTab from './components/WrongTab'; 10 | import { RecordingState, EventLog } from '../types/Events'; 11 | 12 | 13 | export default function App() { 14 | const [isLoaded, setIsLoaded] = useState(false); 15 | const [recordingState, setRecordingState] = useState('off'); 16 | const [onCorrectTab, setOnCorrectTab] = useState(true); 17 | const [recordingTab, setRecordingTab] = useState(null); 18 | // const [tests, setTests] = useState(''); 19 | // const [elementState, setElementState] = useState({}); 20 | const [events, setEvents] = useState([]); 21 | const [restartSwitch, setRestartSwitch] = useState(false); 22 | const navigate = useNavigate(); 23 | 24 | useEffect(() => { 25 | chrome.runtime.sendMessage({type: 'popup-opened'}).then(res => { 26 | console.log(res.recordingState); 27 | console.log('Popup elementStates', res.elementStates); 28 | console.log('Popup events', res.events); 29 | 30 | setRecordingState(res.recordingState); 31 | setRecordingTab(res.recordedTabId); 32 | // setElementState(res.elementStates); 33 | setEvents(res.events); 34 | setIsLoaded(true); 35 | // setTests(res.tests); 36 | if (res.recordedTabId && (res.recordedTabId !== res.activeTabId)) setOnCorrectTab(false); 37 | if (res.recordingState === 'recording') { 38 | navigate('/recorderView'); 39 | } else if (res.recordingState === 'pre-recording'){ 40 | navigate('/pickerView'); 41 | } else if (res.recordingState === 'off'){ 42 | navigate('/pickerView'); 43 | } 44 | }); 45 | }, [onCorrectTab, restartSwitch]); 46 | 47 | const handleRestart = () => { 48 | chrome.runtime.sendMessage({type: 'restart-recording'}); 49 | console.log('onclick handleRestart'); 50 | chrome.action.setBadgeText({text: ''}); 51 | setRestartSwitch(!restartSwitch); 52 | navigate('/pickerView'); 53 | }; 54 | 55 | const onEndClick = () => { 56 | setRecordingState('off'); 57 | chrome.action.setBadgeText({text: ''}); 58 | chrome.runtime.sendMessage({ type: 'stop-recording' }); 59 | }; 60 | 61 | 62 | // Why element not component? 63 | // Why Routes and not Router? 64 | // No switch? 65 | const application = 66 | <> 67 |
    68 | 69 | 70 |

    Parroteer

    71 | 72 |
    73 | 74 | 75 | }/> 80 | }/> 87 | }/> 92 | 93 | 94 | 95 | 102 | ; 103 | 104 | const wrongTab = ; 108 | 109 | return ( 110 | isLoaded ? (onCorrectTab ? application : wrongTab) : 111 | ); 112 | } -------------------------------------------------------------------------------- /__tests__/generateTests.test.ts: -------------------------------------------------------------------------------- 1 | import createTestsFromEvents, { indent } from '../src/app/modules/generateTests'; 2 | import endent from 'endent'; 3 | import { UserInputEvent, PickedElementEvent, MutationEvent, StoredEvent } from '../src/types/Events'; 4 | 5 | const debugScript = endent` 6 | await page.evaluate(() => { 7 | document.querySelector('#cover').addEventListener('click', () => { 8 | document.querySelector('#cover > p').classList.add('test'); 9 | document.querySelector('#cover > h1').classList.add('test'); 10 | document.querySelector('#cover > h1').innerText = 'hi'; 11 | }); 12 | }); 13 | `; 14 | const testURL = 'https://eloquentjavascript.net'; 15 | 16 | const pickedElementEvent1: PickedElementEvent = { 17 | type: 'picked-element', 18 | selector: '#cover > p', 19 | parroteerId: 'f064152d-766c-4b5f-be3f-e483cfee07c7', 20 | displaySelector: '' 21 | }; 22 | const pickedElementEvent2: PickedElementEvent = { 23 | type: 'picked-element', 24 | selector: '#cover > h1', 25 | parroteerId: '345bb623-0069-4c5c-9dbd-ce3faaa8f50e', 26 | displaySelector: '' 27 | }; 28 | const inputEvent1: UserInputEvent = { 29 | type: 'input', 30 | eventType: 'click', 31 | parroteerId: '', 32 | selector: '#cover > h1', 33 | displaySelector: '' 34 | }; 35 | const mutationEvent1: MutationEvent = { 36 | type: 'mutation', 37 | class: 'test', 38 | parroteerId: 'f064152d-766c-4b5f-be3f-e483cfee07c7', 39 | selector: '', 40 | displaySelector: '' 41 | }; 42 | const mutationEvent2: MutationEvent = { 43 | type: 'mutation', 44 | class: 'test', 45 | textContent: 'hi', 46 | parroteerId: '345bb623-0069-4c5c-9dbd-ce3faaa8f50e', 47 | selector: '', 48 | displaySelector: '' 49 | }; 50 | 51 | describe('Basic test generation', () => { 52 | it('should generate basic test setup', () => { 53 | const imports = `const puppeteer = require('puppeteer');`; 54 | const describe = endent` 55 | describe('End-to-end test', () => { 56 | /** @type {puppeteer.Browser} */ let browser; 57 | /** @type {puppeteer.Page} */ let page; 58 | `; 59 | const before = endent` 60 | beforeAll(async () => { 61 | browser = await puppeteer.launch({ headless: false }); 62 | page = await browser.newPage(); 63 | }); 64 | `; 65 | const testBlock = endent` 66 | it('passes this test', async () => { 67 | await page.goto('${testURL}'); 68 | // Temporary variable to store elements when finding and making assertions 69 | let element; 70 | `; 71 | const teardown = endent` 72 | afterAll(async () => { 73 | await browser.close(); 74 | }); 75 | `; 76 | 77 | const generatedTests = createTestsFromEvents([], testURL, debugScript); 78 | expect(generatedTests).toMatch(codeToRegex(imports)); 79 | expect(generatedTests).toMatch(codeToRegex(describe)); 80 | expect(generatedTests).toMatch(codeToRegex(before, 1)); 81 | expect(generatedTests).toMatch(codeToRegex(testBlock, 1)); 82 | expect(generatedTests).toMatch(codeToRegex(teardown, 1)); 83 | }); 84 | 85 | it('should display comment when events is empty', () => { 86 | const noEventText = '// No events were recorded'; 87 | const generatedTests = createTestsFromEvents([], testURL, debugScript); 88 | // console.log(generatedTests); 89 | expect(generatedTests).toMatch(codeToRegex(noEventText)); 90 | }); 91 | 92 | it('should generate a proper test for a simple set of events', () => { 93 | const events: StoredEvent[] = [pickedElementEvent1, pickedElementEvent2, inputEvent1, mutationEvent1, mutationEvent2]; 94 | 95 | const testScript = endent` 96 | await page.waitForSelector('#cover > p').then((el) => { 97 | el.evaluate(el => el.dataset.parroteerId = 'f064152d-766c-4b5f-be3f-e483cfee07c7'); 98 | }); 99 | await page.waitForSelector('#cover > h1').then((el) => { 100 | el.evaluate(el => el.dataset.parroteerId = '345bb623-0069-4c5c-9dbd-ce3faaa8f50e'); 101 | }); 102 | await page.waitForSelector('#cover > h1').then(el => el.click()); 103 | element = await page.$('[data-parroteer-id="f064152d-766c-4b5f-be3f-e483cfee07c7"]'); 104 | expect(getProp(element, 'class')).resolves.toEqual('test'); 105 | element = await page.$('[data-parroteer-id="345bb623-0069-4c5c-9dbd-ce3faaa8f50e"]'); 106 | expect(getProp(element, 'class')).resolves.toEqual('test'); 107 | expect(getProp(element, 'textContent')).resolves.toEqual('hi'); 108 | `; 109 | 110 | const generatedTests = createTestsFromEvents(events, testURL, debugScript); 111 | console.log(generatedTests); 112 | expect(generatedTests).toMatch(codeToRegex(testScript, 2)); 113 | }); 114 | 115 | // TODO: Set up jest-puppeteer and assert that the code is valid and runs properly 116 | }); 117 | 118 | /** 119 | * Escapes a string for use in a regular expression 120 | */ 121 | // Big thanks to coolaj86 on StackOverflow for this! https://stackoverflow.com/a/6969486/12033249 122 | function escapeRegExp(string: string) { 123 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 124 | } 125 | 126 | /** 127 | * Converts a block of code into an escaped regular expression, with optional indentation 128 | */ 129 | function codeToRegex(string: string, tabs = 0, tabSize = 2) { 130 | const indented = indent(string, tabs, tabSize) 131 | return new RegExp(escapeRegExp(indented)); 132 | } -------------------------------------------------------------------------------- /src/app/modules/generateTests.ts: -------------------------------------------------------------------------------- 1 | import endent from 'endent'; 2 | import {PickedElementEvent, MutationEvent, UserInputEvent, StoredEvent } from '../../types/Events'; 3 | 4 | /** 5 | * Creates a full test from an array of StoredEvents 6 | */ 7 | export default function createTestsFromEvents(events: StoredEvent[], url = 'http://localhost:8080', debugScripts = '') { 8 | const imports = 'const puppeteer = require(\'puppeteer\');'; 9 | const header = endent` 10 | describe('End-to-end test', () => { 11 | /** @type {puppeteer.Browser} */ let browser; 12 | /** @type {puppeteer.Page} */ let page; 13 | 14 | beforeAll(async () => { 15 | browser = await puppeteer.launch({ headless: false }); 16 | page = await browser.newPage(); 17 | }); 18 | 19 | it('passes this test', async () => { 20 | await page.goto('${url}'); 21 | // Temporary variable to store elements when finding and making assertions 22 | let element; 23 | `; 24 | 25 | const footer = 26 | ` }); 27 | 28 | afterAll(async () => { 29 | await browser.close(); 30 | }); 31 | }); 32 | `; 33 | 34 | const getPropFunc = endent` 35 | /** 36 | * Gets the value of a property from an Puppeteer element. 37 | * @param {puppeteer.ElementHandle} element 38 | * @param {puppeteer.HandleOr | 'class'} property Passing in 'class' will return the full class string. 39 | */ 40 | async function getProp(element, property) { 41 | switch (property) { 42 | case 'class': 43 | return await element.getProperty('classList').then(cL => cL.getProperty('value')).then(val => val.jsonValue()); 44 | default: 45 | return await element.getProperty(property).then(val => val.jsonValue()); 46 | } 47 | } 48 | `; 49 | 50 | // Not used for now but might be useful later when adding delays 51 | const asyncTimeoutFunc = endent` 52 | /** 53 | * Creates an asynchronous setTimeout which can be awaited 54 | */ 55 | function asyncTimeout(delay) { 56 | return new Promise(resolve => setTimeout(() => resolve(), delay)); 57 | } 58 | `; 59 | 60 | const debug = debugScripts && endent` 61 | // Debug start 62 | ${debugScripts} 63 | // Debug end 64 | `; 65 | 66 | const formattedEvents: string[] = []; 67 | for (const event of events) { 68 | const {type} = event; 69 | // logic for events using puppeteer to simulate user clicks 70 | switch (type) { 71 | case 'input': 72 | formattedEvents.push(puppeteerEventOutline(event as UserInputEvent)); 73 | break; 74 | case 'mutation': 75 | formattedEvents.push(jestOutline(event as MutationEvent)); 76 | break; 77 | case 'picked-element': 78 | formattedEvents.push(pickEvent(event as PickedElementEvent)); 79 | break; 80 | default: 81 | console.log(`how did ${type} get in here???`); 82 | break; 83 | } 84 | } 85 | 86 | const eventsSection = formattedEvents.length > 0 ? formattedEvents.join('\n') : '// No events were recorded'; 87 | 88 | const fullScript = endent` 89 | ${imports} 90 | 91 | ${header} 92 | ${debug && indent('\n' + debug + '\n', 2)} 93 | ${indent(eventsSection, 2)} 94 | ${footer} 95 | 96 | ${getPropFunc} 97 | `; 98 | return fullScript; 99 | } 100 | 101 | /** 102 | * Finds the element associated with the provided MutationEvent 103 | * and generates Jest `expect` statements for each change 104 | */ 105 | const jestOutline = (event: MutationEvent) => { 106 | const expectations: string[] = []; 107 | const checkProps = ['textContent', 'value', 'class']; 108 | for (const prop in event) { 109 | if (!checkProps.includes(prop)) continue; 110 | expectations.push(`expect(getProp(element, '${prop}')).resolves.toEqual('${event[prop as keyof MutationEvent]}');`); 111 | } 112 | 113 | const expectStr = endent` 114 | element = await page.$('[data-parroteer-id="${event.parroteerId}"]'); 115 | ${expectations.join('\n')} 116 | `; 117 | return expectStr; 118 | }; 119 | 120 | 121 | /** 122 | * Finds the element associated with the provided UserInputEvent 123 | * and mimics the input event that happened with it using Puppeteer 124 | */ 125 | const puppeteerEventOutline = (event: UserInputEvent) => { 126 | const { selector } = event; 127 | let puppetStr; 128 | if (event.eventType === 'click') { 129 | puppetStr = endent` 130 | await page.waitForSelector('${selector}').then(el => el.click()); 131 | `; 132 | } 133 | else puppetStr = `await page.keyboard.press('${event.key}')`; 134 | return puppetStr; 135 | }; 136 | 137 | /** 138 | * Finds the element associated with a PickedElementEvent 139 | * and assigns it the parroter Id that it should have 140 | */ 141 | const pickEvent = (event: PickedElementEvent) => { 142 | const pickStr = endent` 143 | await page.waitForSelector('${event.selector}').then((el) => { 144 | el.evaluate(el => el.dataset.parroteerId = '${event.parroteerId}'); 145 | }); 146 | `; 147 | return pickStr; 148 | }; 149 | 150 | /** 151 | * Indents a block of text (per line) by the specified amount of 'tabs' (using spaces) 152 | * @param text 153 | * @param tabs The number of tabs to indent by 154 | * @param tabSize How many spaces a tab should be 155 | * @returns 156 | */ 157 | export function indent(text: string, tabs = 0, tabSize = 2) { 158 | const lines = text.split('\n'); 159 | return lines.map(line => ' '.repeat(tabSize).repeat(tabs) + line).join('\n'); 160 | } -------------------------------------------------------------------------------- /src/app/background.ts: -------------------------------------------------------------------------------- 1 | import { ElementState, EventLog, MutationEvent, ParroteerId, RecordingState, UserInputEvent, PickedElementEvent } from '../types/Events'; 2 | import { RuntimeMessage } from '../types/Runtime'; 3 | import createTestsFromEvents from './modules/generateTests'; 4 | // import senfFinalElements from './modules/generateTests'; 5 | 6 | // This script does not communicate with the DOM 7 | console.log('Running background script (see chrome extensions page)'); 8 | 9 | 10 | 11 | /// Globals 12 | let activeTabId: number; 13 | let recordedTabId: number; 14 | let recordingState: RecordingState = 'off'; 15 | let tests = ''; 16 | let recordingURL: string; 17 | let events: EventLog = []; 18 | 19 | // Initialize object to track element states 20 | let elementStates: { [key: ParroteerId]: ElementState } = {}; 21 | 22 | // Listen for messages from popup or content script 23 | chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => { 24 | console.log('Background got a message!', message); 25 | 26 | switch (message.type) { 27 | case 'popup-opened': 28 | console.log(elementStates); 29 | sendResponse({recordingState, recordedTabId, activeTabId, elementStates, events, tests}); 30 | break; 31 | 32 | case 'begin-recording': { 33 | console.log('In begin-recording switch case'); 34 | recordingState = 'recording'; 35 | addRecordingListeners(recordingState); 36 | // disableHighlight(); 37 | // beginRecording(); 38 | break; 39 | } 40 | 41 | case 'begin-pick-elements': 42 | recordingState = 'pre-recording'; 43 | addRecordingListeners(recordingState); 44 | // enableHighlight(); 45 | break; 46 | 47 | case 'event-triggered': { 48 | const { event, prevMutations } = message.payload as { event: UserInputEvent, prevMutations?: MutationEvent[] }; 49 | switch (recordingState) { 50 | case 'pre-recording': { 51 | if (event.eventType === 'click') { 52 | // When an element is clicked in pre-recording (aka pick mode), track element and notify the content script 53 | if (activeTabId !== recordedTabId) { 54 | throw new Error('Cannot pick elements on wrong tab'); 55 | } 56 | const selector = event.selector; 57 | 58 | // {type: 'picked-element event, parroteerId, initalSelector} 59 | 60 | chrome.tabs.sendMessage( recordedTabId, { type: 'watch-element', payload: selector }, 61 | (elInfo: { state: ElementState, parroteerId: ParroteerId }) => { 62 | elementStates[elInfo.parroteerId] = elInfo.state; 63 | const pickedElementEvent: PickedElementEvent = { 64 | type: 'picked-element', 65 | displaySelector: event.displaySelector, 66 | selector: event.selector, 67 | parroteerId: elInfo.parroteerId 68 | }; 69 | events.push(pickedElementEvent); 70 | console.log('Picked elements:', elementStates); 71 | } 72 | ); 73 | 74 | } 75 | 76 | break; 77 | } 78 | 79 | case 'recording': { 80 | // Message should include the event that occurred as well as any mutations that occurred prior to it 81 | 82 | if (prevMutations) events.push(...prevMutations); 83 | console.log('prevMutations', prevMutations); 84 | events.push(event); 85 | 86 | console.log('Current event log:', events); 87 | break; 88 | } 89 | } 90 | break; 91 | } 92 | 93 | case 'pause-recording': 94 | recordingState = 'off'; 95 | stopRecordingListeners(); 96 | console.log(events); 97 | tests = createTestsFromEvents(events, recordingURL); 98 | break; 99 | 100 | case 'stop-recording': 101 | lastElementStateDiff(); 102 | recordingState = 'off'; 103 | stopRecordingListeners(); 104 | console.log(events); 105 | tests = createTestsFromEvents(events, recordingURL); 106 | // sendResponse(tests); 107 | break; 108 | 109 | case 'restart-recording': 110 | console.log('in restart recording'); 111 | recordingState = 'off'; 112 | tests = ''; 113 | events = []; 114 | stopRecordingListeners(Object.keys(elementStates)); 115 | elementStates = {}; 116 | recordedTabId = null; 117 | break; 118 | 119 | case 'get-tests': 120 | sendResponse(tests); 121 | break; 122 | } 123 | }); 124 | 125 | 126 | /** 127 | * Message the content script and instruct it to add event listeners and observer 128 | */ 129 | function addRecordingListeners(recState: RecordingState) { 130 | recordedTabId = recordedTabId || activeTabId; 131 | chrome.tabs.get(recordedTabId, (res) => recordingURL = res.url); 132 | console.log('ADDING RECORDING LISTENERS FOR TABID', recordedTabId); 133 | chrome.tabs.sendMessage(recordedTabId, { type: 'add-listeners', payload: { recordingState: recState } }); 134 | } 135 | 136 | function stopRecordingListeners(arr?: string[]) { 137 | console.log('Stopping RECORDING LISTENERS FOR TABID', recordedTabId); 138 | chrome.tabs.sendMessage(recordedTabId, { type: 'add-listeners', payload: {idsToClear: arr, recordingState: 'off' } }); 139 | } 140 | 141 | function lastElementStateDiff() { 142 | console.log(`%c${'Going INTO EVENTS'}`, 'background-color: green', events); 143 | chrome.tabs.sendMessage(recordedTabId, { type: 'final-diff'}, (res) => { 144 | console.log(res), 145 | events.push(...res); 146 | }); 147 | } 148 | 149 | /// Tab event listeners 150 | // On change tabs: Set active tab id to current tab id 151 | chrome.tabs.onActivated.addListener((activeInfo) => { 152 | activeTabId = activeInfo.tabId; 153 | }); -------------------------------------------------------------------------------- /src/app/modules/eventListeners.ts: -------------------------------------------------------------------------------- 1 | import getRelativeSelector, { getFullSelector } from './getSelector'; 2 | import { CssSelector, ParroteerId, ElementState, MutationEvent, RecordingState } from '../../types/Events'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | let recordingState: RecordingState = 'off'; 6 | const elementStates: { [key: ParroteerId ]: ElementState } = {}; 7 | 8 | /** 9 | * Stops element monitoring event listeners 10 | */ 11 | export function stopEventListeners() { 12 | document.removeEventListener('click', clickListener, { capture: true }); 13 | document.removeEventListener('keydown', keydownListener, { capture: true }); 14 | } 15 | 16 | /** 17 | * Starts element monitoring event listeners 18 | */ 19 | export function startEventListeners(state: RecordingState) { 20 | // Remove old event listeners in case any are already there 21 | stopEventListeners(); 22 | 23 | recordingState = state; 24 | console.log('Starting event listeners with recording state:', recordingState); 25 | 26 | document.addEventListener('click', clickListener, { capture: true }); 27 | document.onkeydown = (e) => (keydownListener(e)); 28 | } 29 | 30 | /** 31 | * Gets the selector of the target element, then sends a message to the background with the 32 | * event details and details on any element changes that occurred. 33 | * 34 | * If `recordingState` is 'pre-recording', prevents the event from going to any elements 35 | * or triggering default behavior. 36 | */ 37 | function clickListener(event: MouseEvent) { 38 | // TODO: Check event.isTrusted or whatever to see if event was created by user 39 | const target = event.target as HTMLElement; 40 | 41 | if (recordingState === 'pre-recording') { 42 | // If picking elements and the element already has a parroteer ID, do nothing 43 | if ('parroteerId' in target.dataset) return; 44 | 45 | event.stopPropagation(); 46 | event.preventDefault(); 47 | } 48 | 49 | const selector = getRelativeSelector(target); 50 | const displaySelector = getFullSelector(target); 51 | console.log('Element clicked:', selector); 52 | console.log('Element state', elementStates); 53 | const mutations = diffElementStates(); 54 | 55 | chrome.runtime.sendMessage({ 56 | type: 'event-triggered', 57 | payload: { 58 | event: { 59 | type: 'input', 60 | selector, 61 | displaySelector, 62 | eventType: event.type, 63 | timestamp: Date.now(), 64 | parroteerId: target.dataset.parroteerId 65 | }, 66 | prevMutations: mutations 67 | } 68 | }); 69 | } 70 | 71 | function keydownListener(event: KeyboardEvent) { 72 | console.log('keydown event occurred', event); 73 | const target = event.target as HTMLElement; 74 | const key = event.key; 75 | const shift = event.shiftKey; 76 | const code = event.code; 77 | 78 | const selector = getRelativeSelector(target); 79 | const displaySelector = getFullSelector(target); 80 | // OTHER: alt, shift, control keys also pressed? 81 | // const ctrlKey = event.ctrlKey; 82 | const mutations = diffElementStates(); 83 | 84 | chrome.runtime.sendMessage({ 85 | type: 'event-triggered', 86 | payload: { 87 | event: { 88 | type: 'input', 89 | key, 90 | shift, 91 | code, 92 | selector, 93 | displaySelector, 94 | eventType: event.type, 95 | timestamp: event.timeStamp, 96 | parroteerId: target.dataset.parroteerId 97 | }, 98 | prevMutations: mutations 99 | } 100 | }); 101 | } 102 | 103 | 104 | /** 105 | * Tracks an element based on the provided selector and watches it for changes 106 | */ 107 | export function watchElement(selector: CssSelector) { 108 | const parroteerId = assignParroteerId(selector); 109 | elementStates[parroteerId] = getCurrState(parroteerId); 110 | return { 111 | state: elementStates[parroteerId], 112 | parroteerId 113 | }; 114 | } 115 | 116 | /** 117 | * Finds an element in the DOM and assigns it a unique "data-parroteer-id" property 118 | */ 119 | export function assignParroteerId (selector: CssSelector) { 120 | const element = document.querySelector(selector) as HTMLElement; 121 | const uuid = uuidv4(); 122 | element.dataset.parroteerId = uuid; 123 | return uuid; 124 | } 125 | 126 | /** 127 | * Finds an element by parroteerId 128 | */ 129 | function findElementByPId (parroteerId: ParroteerId) { 130 | const selector = `[data-parroteer-id="${parroteerId}"]`; 131 | const el: HTMLElement | HTMLInputElement = document.querySelector(selector); 132 | return el; 133 | } 134 | 135 | /** 136 | * Gets the current state of an element by its CSS selector 137 | */ 138 | export function getCurrState(parroteerId: ParroteerId): ElementState { 139 | const el = findElementByPId(parroteerId); 140 | return { 141 | class: el.classList?.value, 142 | textContent: el.innerText, 143 | value: 'value' in el ? el.value : undefined 144 | }; 145 | } 146 | 147 | /** 148 | * Determines if/what changes have occurred with any watched elements between 149 | * their current state and previously tracked state 150 | */ 151 | export function diffElementStates() { 152 | const changedStates: MutationEvent[] = []; 153 | 154 | for (const parroteerId in elementStates) { 155 | const prevState = elementStates[parroteerId]; 156 | const currState = { 157 | ...prevState, 158 | ...getCurrState(parroteerId) 159 | }; 160 | 161 | // Determine and store element changes 162 | const elChanges = diffState(prevState, currState); 163 | if (elChanges) { 164 | console.log(`Watched element "${parroteerId}" changed state. New properties:`, elChanges); 165 | const el = findElementByPId(parroteerId); 166 | changedStates.push({ 167 | type: 'mutation', 168 | displaySelector: getFullSelector(el), 169 | selector: getRelativeSelector(el), 170 | parroteerId, 171 | ...elChanges 172 | }); 173 | } 174 | 175 | // TODO? Show whether stuff was added or removed? 176 | elementStates[parroteerId] = currState; 177 | } 178 | 179 | return changedStates; 180 | } 181 | 182 | /** 183 | * Determines the difference in state for an element at 2 different points in time 184 | */ 185 | function diffState(prev: ElementState, curr: ElementState): Partial | null { 186 | let differences: Partial | null = null; 187 | // For every property in the previous state, 188 | // check to see if it is different from the same property in the current state 189 | for (const _key in prev) { 190 | const key = _key as keyof ElementState; 191 | if (prev[key] !== curr[key]) { 192 | if (!differences) differences = {}; 193 | differences[key] = curr[key]; 194 | } 195 | } 196 | return differences; 197 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Parroteer_Banner_1640x410](https://user-images.githubusercontent.com/1347847/180268705-d02a2d6a-092c-4835-a3b3-812d1259868a.png) 2 | 3 |
    4 | 5 | [![License](https://img.shields.io/github/license/oslabs-beta/parroteer?color=%2344BED7)](#license) 6 | [![Version_Badge](https://img.shields.io/github/package-json/v/oslabs-beta/parroteer?color=%2344BED7)](#about-parroteer) 7 | [![Contributions](https://img.shields.io/badge/contributions-welcome-brightgreen)](#contributions) 8 | 9 | 10 | ## *No-code test automation tool for end-to-end testing* 11 | 12 |
    13 | 14 | ## Table of Contents 15 | - [About](#about-parroteer) 16 | - [Features](#features) 17 | - [Installation](#installation) 18 | - [Usage](#usage) 19 | - [Creating tests](#creating-tests) 20 | - [Running tests](#running-generated-tests) 21 | - [Roadmap](#roadmap) 22 | - [Our Team](#our-team) 23 | - [Contributions](#contributions) 24 | - [License](#license) 25 | 26 | 27 | ## About Parroteer 28 | Parroteer allows you to generate end-to-end tests simply by using your website. Pick the elements you want to watch for changes, interact with your page just as a user would, and have Puppeteer scripts generated for you with built in test assertions using Jest! 29 | 30 | ### Features 31 | - Select specific elements on the page to observe for changes 32 | - Record user interactions and changes that occur in tracked elements 33 | - Auto-generation of Jest-Puppeteer tests 34 | - View, edit, and copy or download generated code 35 | 36 | ## Installation 37 | You can find our extension in the [Chrome Web Store](https://chrome.google.com/webstore/detail/parroteer/jhmmibbfaefjjbpgpcjomgabpegnmddj) and click "Add to Chrome". Then just pin Parroteer to your extension toolbar and you're ready to go! 38 | 39 | ## Usage 40 | ### Creating tests 41 | 42 | #### 1. Pick elements to watch 43 | Begin by navigating to the page you want to test, then click the Parroteer icon and select "Pick Elements". Now you can highlight and click the elements on the page that you want to watch for changes. 44 | 45 |

    46 | 47 |

    48 | 49 | 50 | #### 2. Record! 51 | Once you're ready, you can go forward and start recording! Parroteer will begin tracking your clicks and key-presses on the page, and as any watched elements change, Parroteer will store these changes and create corresponding tests. 52 | 53 | If say a new element appears on the page that you want to watch or maybe you realized you forgot one, just click the pause button in the extension popup and go back to pick more elements, then resume recording! 54 | 55 | 56 | 57 | #### 3. View and save tests 58 | When you're all set, just find that friendly little parrot again in your extension bar and click the stop button. From there you can view, edit, and copy or export the Puppeteer scripts and Jest tests that are generated for you! 59 | 60 | 61 | 62 | #### 4. Rinse and repeat 63 | When you're ready to start a new recording session or if at any point you want to cancel your current one, all you need to do is click the restart button in the top right. 64 | 65 | ### Running generated tests 66 | Configurations for running Jest-Puppeteer tests may vary, but for a basic setup, we recommend the following: 67 | 1. Add the generated code as a `[filename].test.ts` file in your project's `__tests__` directory 68 | 2. Install [Jest](https://github.com/facebook/jest), [Puppeteer](https://github.com/puppeteer/puppeteer), and [Jest-Puppeteer](https://github.com/smooth-code/jest-puppeteer) via npm 69 | 3. In your project's package.json, add the jest-puppeteer preset: 70 | ```js 71 | { 72 | ... 73 | "jest": { 74 | "preset": "jest-puppeteer" 75 | } 76 | } 77 | ``` 78 | 4. Add a `package.json` script to run jest, or use `npx jest` to run the tests! 79 | 80 | ## Roadmap 81 | There's a lot we'd love to (and plan to!) do with Parroteer! Here's what we've thought of so far: 82 | - Buttons to deselect picked elements and remove recorded events 83 | - Keep all selected elements highlighted while picking elements 84 | - In-browser recording replays via the extension 85 | - Replay controls such as pausing & stepping forward/back 86 | - Saving and loading of previous tests using Chrome storage 87 | - User settings such as: 88 | - Allowing custom properties to be specified for observation 89 | - Customization in how selectors are generated 90 | - Toggle to watch for all DOM changes instead of specific elements 91 | - Add additional DOM events that users can opt to listen for 92 | - Toggle to include delays between user inputs in generated scripts (and replays) 93 | 94 | We also know there are further improvements we can make in how element changes are tracked and how the corresponding tests are generated, as well as our codebase as a whole, so we'll keep making adjustments and smoothing things out wherever we can! 95 | 96 | ## Our Team 97 | 98 | 107 | 116 | 125 | 134 |
    99 | 100 |
    101 | Alex Rokosz 102 |
    103 | GitHub 104 |
    105 | LinkedIn 106 |
    108 | 109 |
    110 | Alina Gasperino 111 |
    112 | GitHub 113 |
    114 | LinkedIn 115 |
    117 | 118 |
    119 | Eric Wells 120 |
    121 | GitHub 122 |
    123 | LinkedIn 124 |
    126 | 127 |
    128 | Erin Zhuang 129 |
    130 | GitHub 131 |
    132 | LinkedIn 133 |
    135 | 136 | ## Contributions 137 | We welcome any and all contributions! If you would like to help out by adding new features or fixing issues, please do the following: 138 | 1. [Fork](https://github.com/oslabs-beta/parroteer/fork) and clone our repository 139 | 2. Run `npm install` to install the necessary dependencies 140 | 3. Run `npm run build-watch` to build the project and watch for changes 141 | 4. Follow the instructions on [loading unpacked extensions](https://developer.chrome.com/docs/extensions/mv3/getstarted/#unpacked) in Chrome 142 | 5. Make changes locally on a feature- or bugfix- branch 143 | 6. Write unit tests for any new features or components that you create 144 | 7. Use `npm test` during development to ensure changes are non-breaking 145 | 8. Finally when you're done, push your branch to your fork and create a pull request! 146 | 147 | Whenever you make changes to your code while running the `build-watch` script, Webpack will automatically rebuild the project. However, in order to see these changes in your extension you must reload the extension in Chrome, then refresh any pages you wish to use it with so that the content scripts are reloaded as well. 148 | 149 | We use a custom eslint configuration and would greatly appreciate that all contributors adhere to the defined styling rules, and please try to follow similar coding patterns as those you may see in this repository 🙂 150 | 151 | ## License 152 | This software is provided under the [MIT License](LICENSE.md). 153 | -------------------------------------------------------------------------------- /src/styles/popup.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat+Alternates:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); 3 | @import url('https://fonts.googleapis.com/css?family=Poppins'); 4 | @import url('https://fonts.googleapis.com/icon?family=Material+Icons'); 5 | 6 | :root { 7 | --extra-light-teal: #EDF8F9; 8 | --light-teal: #84CED7; 9 | --med-teal: #27adbe; 10 | --light-blue: #DADFEE; 11 | --light-gray-blue: #EBF1F4; 12 | --med-blue: #5270e1; 13 | --dark-blue: #344799; 14 | --yellow: #F8D13D; 15 | --med-yellow: #F0AD2B; 16 | --orange: #EC5A3E; 17 | --peach: #ff866f; 18 | --dark-gray: #212121; 19 | --med-gray: #303030; 20 | --light-gray: #424242; 21 | --extra-light-gray: #878787; 22 | } 23 | 24 | $extra-light-teal: #EDF8F9; 25 | $light-teal: #84CED7; 26 | $med-teal: #27adbe; 27 | $light-blue: #DADFEE; 28 | $med-blue: #5270e1; 29 | $dark-blue: #344799; 30 | $yellow: #F8D13D; 31 | $med-yellow: #F0AD2B; 32 | $orange: #EC5A3E; 33 | $peach: #ff866f; 34 | $dark-gray: #212121; 35 | $med-gray: #303030; 36 | $light-gray: #424242; 37 | $extra-light-gray: #878787; 38 | $light-gray-blue: #EBF1F4; 39 | 40 | $font-mont: 'Montserrat Alternates', sans-serif; 41 | $font-poppins: 'Poppins'; 42 | 43 | main { 44 | width: 100%; 45 | height: 100%; 46 | } 47 | 48 | * { 49 | box-sizing: border-box; 50 | } 51 | 52 | html { 53 | background: none; 54 | width: 400px; 55 | font-size: 16px; 56 | font-family: $font-poppins; 57 | color: #fff; 58 | background-image: url('./img/parroteer-layered-waves-bg.png'); 59 | background-position: center; 60 | } 61 | 62 | body, #root { 63 | margin: 0; 64 | width: 100%; 65 | height: 100%; 66 | font-family: 'Source Sans Pro'; 67 | } 68 | 69 | #root { 70 | display: flex; 71 | flex-flow: column nowrap; 72 | justify-content: flex-start; 73 | align-items: center; 74 | // max-height: 400px; 75 | backdrop-filter: blur(16px) saturate(180%); 76 | -webkit-backdrop-filter: blur(16px) saturate(180%); 77 | background-color: rgba(17, 25, 40, 0.75); 78 | border: 1px solid rgba(255, 255, 255, 0.125); 79 | } 80 | 81 | #root > section { 82 | height: 100%; 83 | width: 100%; 84 | display: flex; 85 | flex-flow: column nowrap; 86 | justify-content: flex-start; 87 | align-items: center; 88 | // background-color: $med-gray; 89 | } 90 | 91 | header { 92 | display: flex; 93 | box-shadow: 0 1px 3px rgb(0 0 0 / 10%); 94 | // background-color: $med-gray; 95 | justify-content: center; 96 | align-items: center; 97 | width: 100%; 98 | padding: 0.5em 0em 0.5em 1em; 99 | background-color: rgb(33 33 33 / 47%); 100 | 101 | h1 { 102 | margin: 0; 103 | font-family: $font-mont; 104 | letter-spacing: -1px; 105 | font-size: 2.5em; 106 | font-weight: 600; 107 | font-style: italic; 108 | color: #fff; 109 | } 110 | } 111 | 112 | /* Loader component */ 113 | 114 | .loading-page { 115 | background-color: #424242; 116 | display: flex; 117 | flex-direction: column; 118 | justify-content: center; 119 | align-items: center; 120 | img { 121 | height: fit-content; 122 | width: fit-content; 123 | } 124 | p { 125 | font-family: $font-poppins; 126 | color: #fff; 127 | font-size: 1.2rem; 128 | } 129 | } 130 | 131 | .loading-container { 132 | background: rgba(255,255,255,0.1); 133 | justify-content: flex-start; 134 | border-radius: 100px; 135 | align-items: center; 136 | position: relative; 137 | padding: 0 5px; 138 | display: flex; 139 | height: 30px; 140 | width: 250px; 141 | } 142 | 143 | .loading-bar { 144 | animation: load 5s normal forwards; 145 | border-radius: 100px; 146 | background: var(--yellow); 147 | height: 30px; 148 | width: 0; 149 | } 150 | 151 | @keyframes load { 152 | 0% { width: 5; } 153 | 100% { width: 100%; } 154 | } 155 | 156 | /* Wrong tab component */ 157 | 158 | .wrong-tab-page { 159 | display: flex; 160 | flex-direction: column; 161 | background-color: #fff; 162 | justify-content: center; 163 | align-items: center; 164 | background-color: $light-gray; 165 | 166 | p { 167 | font-size: 1.2em; 168 | color: #fff; 169 | font-family: $font-poppins; 170 | text-align: center; 171 | } 172 | 173 | button { 174 | border: none; 175 | color: #fff; 176 | font-size: 1.2em; 177 | padding: 9px; 178 | margin: 0.5em; 179 | width: 85%; 180 | } 181 | 182 | .findTab-btn { 183 | background-color: $med-blue; 184 | } 185 | 186 | .endTabSession-btn { 187 | background-color: $orange; 188 | } 189 | 190 | .wrongTab-wrapper { 191 | background-color: #303030; 192 | padding: 1em 2em; 193 | margin: 0 2.5em 1.5em 2.5em; 194 | } 195 | } 196 | 197 | /* Scrollbar */ 198 | 199 | ::-webkit-scrollbar { 200 | width: 1.3em; 201 | } 202 | 203 | 204 | // ::-webkit-scrollbar:horizontal { 205 | // width: 1em; 206 | // } 207 | 208 | // ::-webkit-scrollbar:vertical { 209 | // width: 1em; 210 | // } 211 | 212 | // ::-webkit-scrollbar-track { 213 | // background: $med-gray; 214 | // } 215 | 216 | ::-webkit-scrollbar-thumb { 217 | background: $light-gray-blue; 218 | } 219 | 220 | ::-webkit-scrollbar-corner { 221 | background: rgb(255 255 255 / 0%); 222 | } 223 | 224 | /* Scroll list component */ 225 | 226 | .scroll-list { 227 | max-height: 300px; 228 | min-height: 300px; 229 | margin: 0; 230 | padding: .5em 0; 231 | overflow: auto; 232 | overflow-y: auto; 233 | white-space: nowrap; 234 | width: 95%; 235 | background-color: rgb(40 40 40 / 61%); 236 | 237 | li { 238 | color: #fff; 239 | padding: 0.5em; 240 | list-style: none; 241 | line-height: 1.5em; 242 | // border-bottom: 3px solid 3px solid rgb(0 0 0 / 31%); 243 | background-color: #42424273; 244 | margin: 0.3em 1em; 245 | } 246 | } 247 | 248 | /* Buttons */ 249 | 250 | header { 251 | button { 252 | margin-left: auto; 253 | } 254 | } 255 | 256 | button { 257 | background: none; 258 | border: none; 259 | cursor: pointer!important; 260 | }; 261 | 262 | .material-symbols-outlined { 263 | font-size: 2em; 264 | font-variation-settings: 265 | 'FILL' 0, 266 | 'wght' 400, 267 | 'GRAD' 0, 268 | 'opsz' 48 269 | } 270 | 271 | .nav-buttons { 272 | $icon-width: 1.4em; 273 | font-size: 1.2em; 274 | 275 | display: flex; 276 | flex-flow: row nowrap; 277 | width: 100%; 278 | // background-color: $med-gray; 279 | 280 | button { 281 | &.next::before, &.back::after { 282 | content: ''; 283 | width: $icon-width; 284 | } 285 | 286 | &.restart { 287 | background-color: #b8493c; 288 | i { 289 | font-size: 2em; 290 | margin: -0.5em 0; 291 | } 292 | } 293 | 294 | display: flex; 295 | justify-content: space-between; 296 | align-items: center; 297 | width: 50%; 298 | margin: 2.25%; 299 | border: none; 300 | cursor: pointer; 301 | font-size: 1em; 302 | font-family: $font-poppins; 303 | font-weight: bold; 304 | padding: 0.5em 0; 305 | text-transform: uppercase; 306 | background-color: $dark-blue; 307 | background-size: 300% 100%; 308 | color: #fff; 309 | 310 | .icon { 311 | height: $icon-width; 312 | width: $icon-width; 313 | } 314 | 315 | &[disabled] { 316 | color: rgb(130 130 130 / 61%); 317 | background-color: #232323a3; 318 | } 319 | 320 | } 321 | } 322 | 323 | .nav-buttons button:hover { 324 | background-color: $med-blue; 325 | transition: all .2s ease-out; 326 | 327 | &.restart { 328 | background-color: #fb5745; 329 | } 330 | } 331 | 332 | .nav-buttons button[disabled]:hover { 333 | background-color: #232323a3; 334 | } 335 | 336 | .actionBtns { 337 | display: flex; 338 | align-items: center; 339 | width: 100%; 340 | background-color: #212121c7; 341 | padding: .5em .5em .5em 1.5em; 342 | margin-bottom: 1em; 343 | } 344 | 345 | .add-btn { 346 | height: 1.3em; 347 | width: 1.3em; 348 | font-size:2em; 349 | background: $med-yellow; 350 | border-radius: 50%; 351 | color: #fff; 352 | justify-content: center; 353 | align-items: center; 354 | font-weight: 500; 355 | z-index: 5; 356 | display: flex; 357 | box-shadow: 0px 2px 10px 0px rgb(0 0 0 / 40%); 358 | border: 0px; 359 | transition: all .2s ease-out; 360 | } 361 | 362 | .add-btn:hover { 363 | background: $yellow; 364 | border: 0px; 365 | transform: scale(1.1); 366 | transition: all .2s ease-out; 367 | } 368 | 369 | .add-icon { 370 | font-size: 1em; 371 | color: #fff; 372 | font-variation-settings: 'wght' 600; 373 | } 374 | 375 | .export-btn { 376 | height: 2.5em; 377 | width: 2.5em; 378 | background: $med-teal; 379 | border-radius: 50%; 380 | color: #fff; 381 | justify-content: center; 382 | align-items: center; 383 | font-weight: 500; 384 | z-index: 5; 385 | display: flex; 386 | box-shadow: 0px 2px 10px 0px rgb(0 0 0 / 40%); 387 | border: 0px; 388 | transition: all .2s ease-out; 389 | } 390 | 391 | .export-btn:hover { 392 | background: $light-teal; 393 | border: 0px; 394 | transform: scale(1.1); 395 | transition: all .2s ease-out; 396 | } 397 | 398 | .export-icon { 399 | font-size: 1.7em; 400 | color: #fff; 401 | font-variation-settings: 'wght' 600; 402 | } 403 | 404 | #testsView { 405 | position: relative; 406 | .copy-btn { 407 | height: 2.5em; 408 | width: 2.5em; 409 | background: #27adbe; 410 | border-radius: 50%; 411 | color: #fff; 412 | justify-content: center; 413 | align-items: center; 414 | font-weight: 500; 415 | z-index: 5; 416 | display: flex; 417 | box-shadow: 0px 2px 10px 0pxrgba(0,0,0,.4); 418 | border: 0px; 419 | transition: all .2s ease-out; 420 | bottom: 2em; 421 | right: 1.5em; 422 | position: absolute; 423 | z-index: 50; 424 | } 425 | 426 | .copy-btn:hover { 427 | background: $light-teal; 428 | border: 0px; 429 | transform: scale(1.1); 430 | transition: all .2s ease-out; 431 | } 432 | 433 | .copy-icon { 434 | font-size: 1.7em; 435 | color: #fff; 436 | font-variation-settings: 'wght' 600; 437 | } 438 | 439 | } 440 | 441 | .record-icon { 442 | color: $orange; 443 | font-size: 3em; 444 | transition: all .2s ease-out; 445 | } 446 | 447 | .record-icon:hover { 448 | transform: scale(1.1); 449 | transition: all .2s ease-out; 450 | } 451 | 452 | .pause-icon { 453 | color: $yellow; 454 | font-size: 3em; 455 | transition: all .2s ease-out; 456 | } 457 | 458 | .pause-icon:hover { 459 | transform: scale(1.1); 460 | transition: all .2s ease-out; 461 | } 462 | 463 | .stop-icon { 464 | color: $med-yellow; 465 | font-size: 3em; 466 | transition: all .2s ease-out; 467 | } 468 | 469 | .stop-icon:hover { 470 | transform: scale(1.1); 471 | transition: all .2s ease-out; 472 | } 473 | 474 | .play-icon { 475 | color: $light-teal; 476 | transition: all .2s ease-out; 477 | } 478 | 479 | .play-icon:hover { 480 | transform: scale(1.1); 481 | transition: all .2s ease-out; 482 | } 483 | 484 | .restart-icon { 485 | color: #fff; 486 | font-size: 2.5em; 487 | } 488 | 489 | .next-icon { 490 | color: #fff; 491 | font-size: 1.5em; 492 | } 493 | 494 | .back-icon { 495 | font-size: 1.5em; 496 | } 497 | 498 | .actionBtns p { 499 | font-size: 1.3em; 500 | margin-left: 1em; 501 | } 502 | 503 | .logo-icon { 504 | transform: scaleX(-1); 505 | width: 40px; 506 | } 507 | 508 | #recorderView .actionBtns { 509 | padding-left: .5em; 510 | } 511 | 512 | #recorderView .actionBtns p { 513 | margin-left: .5em; 514 | } 515 | 516 | /* CodeMirror theme */ 517 | 518 | .ͼ1c { 519 | color: $med-yellow; 520 | } 521 | 522 | .ͼ1a { 523 | color: $peach; 524 | } 525 | 526 | .ͼ15 .cm-gutters { 527 | background-color: #282a36; 528 | color: $light-gray; 529 | } --------------------------------------------------------------------------------