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:
radio_button_checked ,
44 | pause:
pause_circle ,
45 | Resume:
play_circle ,
46 | end:
stop_circle
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 | restart_alt
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 | 
2 |
3 |
4 |
5 | [](#license)
6 | [](#about-parroteer)
7 | [](#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 |
99 |
100 |
101 | Alex Rokosz
102 |
103 | GitHub
104 |
105 | LinkedIn
106 |
107 |
108 |
109 |
110 | Alina Gasperino
111 |
112 | GitHub
113 |
114 | LinkedIn
115 |
116 |
117 |
118 |
119 | Eric Wells
120 |
121 | GitHub
122 |
123 | LinkedIn
124 |
125 |
126 |
127 |
128 | Erin Zhuang
129 |
130 | GitHub
131 |
132 | LinkedIn
133 |
134 |
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 | }
--------------------------------------------------------------------------------