diff --git a/src/pages/Background/index.ts b/src/pages/Background/index.ts index 20a8fbb..57f8bf3 100644 --- a/src/pages/Background/index.ts +++ b/src/pages/Background/index.ts @@ -36,15 +36,17 @@ async function onNavEvent( details: chrome.webNavigation.WebNavigationTransitionCallbackDetails ) { const { tabId, url, transitionType, transitionQualifiers, frameId } = details; - const { recording, recordingTabId, recordingState } = await localStorageGet([ - 'recording', - 'recordingState', - 'recordingTabId', - ]); + const { recording, recordingTabId, recordingFrameId, recordingState } = + await localStorageGet([ + 'recording', + 'recordingState', + 'recordingTabId', + 'recordingFrameId', + ]); // Check if it's a parent frame, we're recording, and it's the right tabid if ( - frameId !== 0 || + frameId !== recordingFrameId || recordingState !== 'active' || recordingTabId !== tabId ) { @@ -75,7 +77,7 @@ chrome.runtime.onMessage.addListener(async function ( throw new Error('New tab id not defined'); } - setStartRecordingStorage(tabId, newUrl, testEditorTabId); + setStartRecordingStorage(tabId, 0, newUrl, testEditorTabId); } else if (request.type === 'forward-recording') { // Focus the original deploysentinel webapp tab post-recording chrome.tabs.update(request.tabId, { active: true }); @@ -103,19 +105,22 @@ chrome.webNavigation.onCommitted.addListener(onNavEvent); chrome.webNavigation.onCompleted.addListener(async (details) => { const { tabId, frameId } = details; - if (frameId !== 0) { - return; - } + const { recordingTabId, recordingFrameId, recordingState } = + await localStorageGet([ + 'recordingTabId', + 'recordingFrameId', + 'recordingState', + ]); - const { recordingTabId, recordingState } = await localStorageGet([ - 'recordingTabId', - 'recordingState', - ]); - if (tabId != recordingTabId || recordingState != 'active') { + if ( + frameId !== recordingFrameId || + tabId != recordingTabId || + recordingState != 'active' + ) { return; } - executeScript(tabId, 'contentScript.bundle.js'); + executeScript(tabId, recordingFrameId, 'contentScript.bundle.js'); }); chrome.contextMenus.removeAll(); diff --git a/src/pages/Common/ScriptTypeSelect.tsx b/src/pages/Common/ScriptTypeSelect.tsx new file mode 100644 index 0000000..aae02bc --- /dev/null +++ b/src/pages/Common/ScriptTypeSelect.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react'; +import { ScriptType } from '../types'; + +export default function ScriptTypeSelect({ + value, + onChange, + color, + fontSize, + shortDescription, +}: { + value: ScriptType; + onChange: (val: ScriptType) => void; + color?: string; + fontSize?: number; + shortDescription?: boolean; +}) { + return ( + + ); +} diff --git a/src/pages/Common/hooks.ts b/src/pages/Common/hooks.ts new file mode 100644 index 0000000..2354ee5 --- /dev/null +++ b/src/pages/Common/hooks.ts @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; + +import { + setPreferredLibraryStorage, + setPreferredBarPositionStorage, + localStorageGet, +} from './utils'; +import { ScriptType, BarPosition } from '../types'; + +export function usePreferredLibrary() { + const [preferredLibrary, setPreferredLibrary] = useState( + null + ); + + useEffect(() => { + localStorageGet(['preferredLibrary']).then( + ({ preferredLibrary: storedPreferredLibrary }) => { + setPreferredLibrary(storedPreferredLibrary); + } + ); + }, []); + + const setPreferredLibraryWithStorage = (library: ScriptType) => { + setPreferredLibrary(library); + setPreferredLibraryStorage(library); + }; + + return [preferredLibrary, setPreferredLibraryWithStorage] as const; +} + +export function usePreferredBarPosition(defaultPosition: BarPosition) { + const [preferredBarPosition, setPreferredBarPosition] = + useState(defaultPosition); + + useEffect(() => { + localStorageGet(['preferredBarPosition']).then( + ({ preferredBarPosition: storedPreferredBarPosition }) => { + setPreferredBarPosition(storedPreferredBarPosition ?? defaultPosition); + } + ); + }, []); + + const setPreferredBarPositionWithStorage = (barPosition: BarPosition) => { + setPreferredBarPosition(barPosition); + setPreferredBarPositionStorage(barPosition); + }; + + return [preferredBarPosition, setPreferredBarPositionWithStorage] as const; +} diff --git a/src/pages/Common/styles.css b/src/pages/Common/styles.css index c8d6f3c..a191042 100644 --- a/src/pages/Common/styles.css +++ b/src/pages/Common/styles.css @@ -91,6 +91,9 @@ .justify-between { justify-content: space-between; } +.justify-content-center { + justify-content: center; +} .items-center { align-items: center; } diff --git a/src/pages/Common/utils.ts b/src/pages/Common/utils.ts index 4641c32..9c38546 100644 --- a/src/pages/Common/utils.ts +++ b/src/pages/Common/utils.ts @@ -2,18 +2,29 @@ export function setEndRecordingStorage() { chrome.storage.local.set({ recordingState: 'finished', recordingTabId: null, + recordingFrameId: null, returnTabId: null, }); } +export function setPreferredLibraryStorage(library: string) { + chrome.storage.local.set({ preferredLibrary: library }); +} + +export function setPreferredBarPositionStorage(position: string) { + chrome.storage.local.set({ preferredBarPosition: position }); +} + export function setStartRecordingStorage( tabId: number, + frameId: number, newUrl: string, returnTabId?: number ) { const storage = { recordingState: 'active', recordingTabId: tabId, + recordingFrameId: frameId, recording: [ { type: 'load', @@ -72,20 +83,47 @@ export async function getCurrentTab(): Promise { return tab; } -export async function executeScript(tabId: number, file: string) { +// Determins if the current tab is a Cypress test tab +export function isCypressBrowser(tabId: number) { + if (typeof browser === 'object') { + return browser.tabs + .executeScript(tabId, { + code: `document.querySelector('script[src*="/__cypress"]') != null`, + }) + .then((result) => (result?.[0] ?? false) as boolean); + } else { + return new Promise((resolve, reject) => { + chrome.scripting.executeScript( + { + target: { tabId }, + func: () => + document.querySelector('script[src*="/__cypress"]') != null, + }, + (executedScript) => resolve(executedScript?.[0]?.result ?? false) + ); + }); + } +} + +export async function executeScript( + tabId: number, + frameId: number, + file: string +) { if (typeof browser === 'object') { - await browser.tabs.executeScript(tabId, { file }); + await browser.tabs.executeScript(tabId, { file, frameId }); } else { await chrome.scripting.executeScript({ - target: { tabId }, + target: { tabId, frameIds: [frameId] }, files: [file], }); } } -export async function executeCleanUp(tabId: number) { +export async function executeCleanUp(tabId: number, frameId: number) { if (typeof browser === 'object') { await browser.tabs.executeScript(tabId, { + frameId, code: ` if (typeof window?.__DEPLOYSENTINEL_CLEAN_UP === 'function') { window.__DEPLOYSENTINEL_CLEAN_UP(); @@ -94,7 +132,7 @@ export async function executeCleanUp(tabId: number) { }); } else { await chrome.scripting.executeScript({ - target: { tabId }, + target: { tabId, frameIds: [frameId] }, func: () => { if (typeof window?.__DEPLOYSENTINEL_CLEAN_UP === 'function') { window.__DEPLOYSENTINEL_CLEAN_UP(); diff --git a/src/pages/Content/ControlBar.tsx b/src/pages/Content/ControlBar.tsx index 2a55b53..acd66c4 100644 --- a/src/pages/Content/ControlBar.tsx +++ b/src/pages/Content/ControlBar.tsx @@ -19,9 +19,17 @@ import ActionList from './ActionList'; import CodeGen from './CodeGen'; import genSelectors, { getBestSelectorForAction } from '../builders/selector'; import { genCode } from '../builders'; +import ScriptTypeSelect from '../Common/ScriptTypeSelect'; +import { usePreferredLibrary, usePreferredBarPosition } from '../Common/hooks'; import type { Action } from '../types'; -import { ActionType, ActionsMode, ScriptType, TagName } from '../types'; +import { + ActionType, + ActionsMode, + ScriptType, + TagName, + BarPosition, +} from '../types'; import ControlBarStyle from './ControlBar.css'; import { endRecording } from '../Common/endRecording'; @@ -100,6 +108,10 @@ function isElementFromOverlay(element: HTMLElement) { } export default function ControlBar({ onExit }: { onExit: () => void }) { + const [barPosition, setBarPosition] = usePreferredBarPosition( + BarPosition.Bottom + ); + const [hoveredElement, setHoveredElement] = useState( null ); @@ -114,9 +126,7 @@ export default function ControlBar({ onExit }: { onExit: () => void }) { const [showActionsMode, setShowActionsMode] = useState( ActionsMode.Code ); - const [scriptType, setScriptType] = useState( - ScriptType.Playwright - ); + const [preferredLibrary, setPreferredLibrary] = usePreferredLibrary(); const [copyCodeConfirm, setCopyCodeConfirm] = useState(false); const [screenshotConfirm, setScreenshotConfirm] = useState(false); @@ -133,7 +143,6 @@ export default function ControlBar({ onExit }: { onExit: () => void }) { // Show Code setShowAllActions(true); - setScriptType(ScriptType.Playwright); // Clear out highlighter document.removeEventListener('mousemove', handleMouseMoveRef.current, true); @@ -199,6 +208,8 @@ export default function ControlBar({ onExit }: { onExit: () => void }) { }); }, []); + const displayedScriptType = preferredLibrary ?? ScriptType.Playwright; + const rect = hoveredElement?.getBoundingClientRect(); const displayedSelector = getBestSelectorForAction( { @@ -213,7 +224,7 @@ export default function ControlBar({ onExit }: { onExit: () => void }) { hoveredElement?.children?.length === 0 && hoveredElement?.innerText?.length > 0, }, - ScriptType.Playwright + displayedScriptType ); if (isOpen === false) { @@ -230,6 +241,11 @@ export default function ControlBar({ onExit }: { onExit: () => void }) { className="ControlBar rr-ignore" id="overlay-controls" style={{ + ...(barPosition === BarPosition.Bottom + ? { + bottom: 35, + } + : { top: 35 }), height: showAllActions ? 330 : 100, }} > @@ -275,8 +291,18 @@ export default function ControlBar({ onExit }: { onExit: () => void }) {
Last Action
-
-
Recording
+
+ setBarPosition( + barPosition === BarPosition.Bottom + ? BarPosition.Top + : BarPosition.Bottom + ) + } + > + Move Overlay to{' '} + {barPosition === BarPosition.Bottom ? 'Top' : 'Bottom'}
void }) { } onClick={() => setShowAllActions(!showAllActions)} > - {showAllActions ? 'Show Less' : 'Show More'}{' '} + {showAllActions ? 'Collapse Overlay' : 'Expand Overlay'}{' '} @@ -351,31 +377,12 @@ export default function ControlBar({ onExit }: { onExit: () => void }) {
{showActionsMode === ActionsMode.Code && ( <> - + { setCopyCodeConfirm(true); setTimeout(() => { @@ -401,7 +408,7 @@ export default function ControlBar({ onExit }: { onExit: () => void }) {
{showActionsMode === ActionsMode.Code && ( - + )} {showActionsMode === ActionsMode.Actions && ( diff --git a/src/pages/Popup/Popup.tsx b/src/pages/Popup/Popup.tsx index a58001f..646d06b 100644 --- a/src/pages/Popup/Popup.tsx +++ b/src/pages/Popup/Popup.tsx @@ -21,7 +21,10 @@ import { getCurrentTab, executeScript, executeCleanUp, + isCypressBrowser, } from '../Common/utils'; +import { usePreferredLibrary } from '../Common/hooks'; +import ScriptTypeSelect from '../Common/ScriptTypeSelect'; import type { Action } from '../types'; import { ActionsMode, ScriptType } from '../types'; @@ -38,14 +41,15 @@ function LastStepPanel({ actions: Action[]; onBack: () => void; }) { + const [preferredLibrary, setPreferredLibrary] = usePreferredLibrary(); + const [showActionsMode, setShowActionsMode] = useState( ActionsMode.Code ); - const [scriptType, setScriptType] = useState( - ScriptType.Playwright - ); const [copyCodeConfirm, setCopyCodeConfirm] = useState(false); + const displayedScriptType = preferredLibrary ?? ScriptType.Playwright; + return (
@@ -81,23 +85,12 @@ function LastStepPanel({ {showActionsMode === ActionsMode.Code && (
- + setPreferredLibrary(val)} + value={displayedScriptType} + /> { setCopyCodeConfirm(true); setTimeout(() => { @@ -120,7 +113,7 @@ function LastStepPanel({
@@ -135,6 +128,8 @@ function LastStepPanel({ } const Popup = () => { + const [preferredLibrary, setPreferredLibrary] = usePreferredLibrary(); + const [recordingTabId, setRecordingTabId] = useState(null); const [currentTabId, setCurrentTabId] = useState(null); @@ -142,9 +137,7 @@ const Popup = () => { const [isShowingLastTest, setIsShowingLastTest] = useState(false); - const [showBetaCTA, setShowBetaCTA] = useState( - localStorage.getItem('showBetaCta') !== 'false' - ); + const [showBetaCTA, setShowBetaCTA] = useState(false); useEffect(() => { localStorageGet(['recording', 'recordingTabId']).then( @@ -175,6 +168,40 @@ const Popup = () => { }); }, []); + // Sets Cypress as default library if we're in the Cypress test browser + useEffect(() => { + (async () => { + const currentTab = await getCurrentTab(); + const tabId = currentTab.id; + if (tabId == undefined) { + return; + } + const isCypress = await isCypressBrowser(tabId); + if (isCypress) { + setPreferredLibrary(ScriptType.Cypress); + } + })(); + }, []); + + function getCypressAutFrame(tabId: number) { + return new Promise( + (resolve, reject) => { + chrome.webNavigation.getAllFrames({ tabId }, (frames) => { + const autFrame = frames?.filter((frame) => { + // Must be child of parent frame and not have "__cypress" in the url + return ( + frame.parentFrameId === 0 && frame.url.indexOf('__cypress') === -1 + ); + })?.[0]; + if (autFrame == null || autFrame.frameId == null) { + return reject(new Error('No AUT frame found')); + } + resolve(autFrame); + }); + } + ); + } + const onRecordNewTestClick = async () => { onNewRecording(); @@ -185,12 +212,24 @@ const Popup = () => { throw new Error('No tab id found'); } - // Let everyone know we should be recording with this current tab - // Clear out event buffer - setStartRecordingStorage(tabId, currentTab.url || ''); + const isCypress = await isCypressBrowser(tabId); + if (isCypress) { + const autFrame = await getCypressAutFrame(tabId); + if (autFrame == null) { + throw new Error('No AUT frame found'); + } + + const frameId = autFrame.frameId; + const frameUrl = autFrame.url; - await executeCleanUp(tabId); - await executeScript(tabId, 'contentScript.bundle.js'); + setStartRecordingStorage(tabId, frameId, frameUrl); + await executeCleanUp(tabId, frameId); + await executeScript(tabId, frameId, 'contentScript.bundle.js'); + } else { + setStartRecordingStorage(tabId, 0, currentTab.url || ''); + await executeCleanUp(tabId, 0); + await executeScript(tabId, 0, 'contentScript.bundle.js'); + } window.close(); }; @@ -263,8 +302,8 @@ const Popup = () => { }} className="text-grey mt-6" > - Generate Playwright & Puppeteer scripts from your browser - actions (ex. click, type, scroll). + Generate Cypress, Playwright & Puppeteer scripts from your + browser actions (ex. click, type, scroll).
+
+
+
Preferred Library:  
+ +
+
{ - this.pushCodes(`await page.waitForSelector('text=${text}')`); + this.pushCodes(`await page.waitForSelector('text=${text}');`); return this; }; @@ -554,42 +554,42 @@ ${this.codes.join('')} export class CypressScriptBuilder extends ScriptBuilder { // Cypress automatically detects and waits for the page to finish loading click = (selector: string, causesNavigation: boolean) => { - this.pushCodes(`cy.get('${selector}').click()`); + this.pushCodes(`cy.get('${selector}').click();`); return this; }; hover = (selector: string, causesNavigation: boolean) => { - this.pushCodes(`cy.get('${selector}').trigger('mouseover')`); + this.pushCodes(`cy.get('${selector}').trigger('mouseover');`); return this; }; load = (url: string) => { - this.pushCodes(`cy.visit('${url}')`); + this.pushCodes(`cy.visit('${url}');`); return this; }; resize = (width: number, height: number) => { - this.pushCodes(`cy.viewport(${width}, ${height})`); + this.pushCodes(`cy.viewport(${width}, ${height});`); return this; }; fill = (selector: string, value: string, causesNavigation: boolean) => { - this.pushCodes(`cy.get('${selector}').type(${JSON.stringify(value)})`); + this.pushCodes(`cy.get('${selector}').type(${JSON.stringify(value)});`); return this; }; type = (selector: string, value: string, causesNavigation: boolean) => { - this.pushCodes(`cy.get('${selector}').type(${JSON.stringify(value)})`); + this.pushCodes(`cy.get('${selector}').type(${JSON.stringify(value)});`); return this; }; select = (selector: string, option: string, causesNavigation: boolean) => { - this.pushCodes(`cy.get('${selector}').select('${option}')`); + this.pushCodes(`cy.get('${selector}').select('${option}');`); return this; }; keydown = (selector: string, key: string, causesNavigation: boolean) => { - this.pushCodes(`cy.get('${selector}').type('{${key}}')`); + this.pushCodes(`cy.get('${selector}').type('{${key}}');`); return this; }; @@ -602,25 +602,25 @@ export class CypressScriptBuilder extends ScriptBuilder { this.pushCodes( `cy.scrollTo(${Math.floor(pageXOffset ?? 0)}, ${Math.floor( pageYOffset ?? 0 - )})` + )});` ); return this; }; fullScreenshot = () => { - this.pushCodes(`cy.screenshot()`); + this.pushCodes(`cy.screenshot();`); return this; }; awaitText = (text: string) => { - this.pushCodes(`cy.contains('${text}')`); + this.pushCodes(`cy.contains('${text}');`); return this; }; buildScript = () => { return `it('Written with DeploySentinel Recorder', () => {${this.codes.join( '' - )}})`; + )}});`; }; } diff --git a/src/pages/types/index.ts b/src/pages/types/index.ts index dcfd332..e4d444e 100644 --- a/src/pages/types/index.ts +++ b/src/pages/types/index.ts @@ -3,6 +3,11 @@ export enum ActionsMode { Code = 'code', } +export enum BarPosition { + Top = 'top', + Bottom = 'bottom', +} + export enum ScriptType { Puppeteer = 'puppeteer', Playwright = 'playwright', diff --git a/tests/builders.test.ts b/tests/builders.test.ts index bd6e834..060fd5b 100644 --- a/tests/builders.test.ts +++ b/tests/builders.test.ts @@ -26,10 +26,10 @@ describe('Test builders', () => { const output = builder .pushComments('// hello-world') - .pushCodes('cy.visit()') + .pushCodes('cy.visit();') .buildScript(); expect(output).toBe( - `it('Written with DeploySentinel Recorder', () => {\n // hello-world\n cy.visit()\n})` + `it('Written with DeploySentinel Recorder', () => {\n // hello-world\n cy.visit();\n});` ); }); @@ -37,7 +37,7 @@ describe('Test builders', () => { builder.click('selector', true); expect(mockPushCodes).toHaveBeenNthCalledWith( 1, - "cy.get('selector').click()" + "cy.get('selector').click();" ); }); @@ -45,25 +45,25 @@ describe('Test builders', () => { builder.hover('selector', true); expect(mockPushCodes).toHaveBeenNthCalledWith( 1, - "cy.get('selector').trigger('mouseover')" + "cy.get('selector').trigger('mouseover');" ); }); test('load', () => { builder.load('url'); - expect(mockPushCodes).toHaveBeenNthCalledWith(1, "cy.visit('url')"); + expect(mockPushCodes).toHaveBeenNthCalledWith(1, "cy.visit('url');"); }); test('resize', () => { builder.resize(1, 2); - expect(mockPushCodes).toHaveBeenNthCalledWith(1, 'cy.viewport(1, 2)'); + expect(mockPushCodes).toHaveBeenNthCalledWith(1, 'cy.viewport(1, 2);'); }); test('fill', () => { builder.fill('selector', 'value', true); expect(mockPushCodes).toHaveBeenNthCalledWith( 1, - 'cy.get(\'selector\').type("value")' + 'cy.get(\'selector\').type("value");' ); }); @@ -71,7 +71,7 @@ describe('Test builders', () => { builder.type('selector', 'value', true); expect(mockPushCodes).toHaveBeenNthCalledWith( 1, - 'cy.get(\'selector\').type("value")' + 'cy.get(\'selector\').type("value");' ); }); @@ -79,7 +79,7 @@ describe('Test builders', () => { builder.select('selector', 'option', true); expect(mockPushCodes).toHaveBeenNthCalledWith( 1, - "cy.get('selector').select('option')" + "cy.get('selector').select('option');" ); }); @@ -87,23 +87,23 @@ describe('Test builders', () => { builder.keydown('selector', 'Enter', true); expect(mockPushCodes).toHaveBeenNthCalledWith( 1, - "cy.get('selector').type('{Enter}')" + "cy.get('selector').type('{Enter}');" ); }); test('wheel', () => { builder.wheel(5, 6, 1, 2); - expect(mockPushCodes).toHaveBeenNthCalledWith(1, 'cy.scrollTo(1, 2)'); + expect(mockPushCodes).toHaveBeenNthCalledWith(1, 'cy.scrollTo(1, 2);'); }); test('fullScreenshot', () => { builder.fullScreenshot(); - expect(mockPushCodes).toHaveBeenNthCalledWith(1, 'cy.screenshot()'); + expect(mockPushCodes).toHaveBeenNthCalledWith(1, 'cy.screenshot();'); }); test('awaitText', () => { builder.awaitText('text'); - expect(mockPushCodes).toHaveBeenNthCalledWith(1, "cy.contains('text')"); + expect(mockPushCodes).toHaveBeenNthCalledWith(1, "cy.contains('text');"); }); }); @@ -250,7 +250,7 @@ test('Written with DeploySentinel Recorder', async ({ page }) => { test('awaitText', () => { builder.awaitText('foo'); expect(builder.getLatestCode()).toBe( - "\n await page.waitForSelector('text=foo')\n" + "\n await page.waitForSelector('text=foo');\n" ); }); });