From 8dee4b34127063981bb331a0716e43fda1df8aac Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Fri, 3 Feb 2023 00:43:48 +0900 Subject: [PATCH 01/14] chore: use contextIsolation mode --- common/types.d.ts | 3 +++ electron/electron.ts | 11 +++++------ electron/execution.ts | 7 ++++--- electron/preload.ts | 10 ++++++++++ src/components/SaveCodeButton.tsx | 2 +- src/renderer.d.ts | 7 +++++++ 6 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 electron/preload.ts create mode 100644 src/renderer.d.ts diff --git a/common/types.d.ts b/common/types.d.ts index 5f6eac1f..5fd34893 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -115,3 +115,6 @@ export type GenerateCodeOptions = { actions: Steps; isProject: boolean; }; +export interface IElectronAPI { + exportScript: (string) => Promise, +} diff --git a/electron/electron.ts b/electron/electron.ts index 95f5e19c..b8c4fd64 100644 --- a/electron/electron.ts +++ b/electron/electron.ts @@ -22,8 +22,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { join } from 'path'; -import { app, BrowserWindow, Menu } from 'electron'; +import path from 'path'; +import { app, BrowserWindow, ipcMain, Menu } from 'electron'; import isDev from 'electron-is-dev'; import unhandled from 'electron-unhandled'; import debug from 'electron-debug'; @@ -35,7 +35,7 @@ import { EventEmitter } from 'events'; unhandled({ logger: err => logger.error(err) }); debug({ isEnabled: true, showDevTools: false }); -const BUILD_DIR = join(__dirname, '..', '..', 'build'); +const BUILD_DIR = path.join(__dirname, '..', '..', 'build'); // We can't read from the `env` file within `services` here // so we must access the process env directly @@ -51,8 +51,7 @@ async function createWindow() { minWidth: 800, webPreferences: { devTools: isDev || IS_TEST, - nodeIntegration: true, - contextIsolation: false, + preload: path.join(__dirname, 'preload.js') }, }); @@ -61,7 +60,7 @@ async function createWindow() { } else if (IS_TEST && TEST_PORT) { win.loadURL(`http://localhost:${TEST_PORT}`); } else { - win.loadFile(join(BUILD_DIR, 'index.html')); + win.loadFile(path.join(BUILD_DIR, 'index.html')); } win.on('close', () => { mainWindowEmitter.emit(MainWindowEvent.MAIN_CLOSE); diff --git a/electron/execution.ts b/electron/execution.ts index 1b28b1f2..d65594e7 100644 --- a/electron/execution.ts +++ b/electron/execution.ts @@ -28,7 +28,7 @@ import { existsSync } from 'fs'; import { writeFile, rm, mkdir } from 'fs/promises'; import { ipcMain as ipc } from 'electron-better-ipc'; import { EventEmitter, once } from 'events'; -import { dialog, shell, BrowserWindow } from 'electron'; +import { dialog, shell, BrowserWindow, ipcMain } from 'electron'; import { fork, ChildProcess } from 'child_process'; import logger from 'electron-log'; import isDev from 'electron-is-dev'; @@ -351,7 +351,7 @@ function onTest(mainWindowEmitter: EventEmitter) { }; } -async function onFileSave(code: string) { +async function onFileSave(_event, code: string) { const window = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; const { filePath, canceled } = await dialog.showSaveDialog(window, { filters: [ @@ -409,11 +409,12 @@ async function onLinkExternal(url: string) { * is destroyed or they will leak/block the next window from interacting with top-level app state. */ export default function setupListeners(mainWindowEmitter: EventEmitter) { + ipcMain.handle('export-script', onFileSave); return [ ipc.answerRenderer('record-journey', onRecordJourneys(mainWindowEmitter)), ipc.answerRenderer('run-journey', onTest(mainWindowEmitter)), ipc.answerRenderer('actions-to-code', onGenerateCode), - ipc.answerRenderer('save-file', onFileSave), + // ipc.answerRenderer('save-file', onFileSave), ipc.answerRenderer('set-mode', onSetMode), ipc.answerRenderer('link-to-external', onLinkExternal), ]; diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 00000000..28243a9c --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,10 @@ +import type { IElectronAPI } from '../common/types'; +import { contextBridge, ipcRenderer } from 'electron'; + +const electronAPI: IElectronAPI = { + exportScript: async (contents) => { + return await ipcRenderer.invoke('export-script', contents); + } +} + +contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/components/SaveCodeButton.tsx b/src/components/SaveCodeButton.tsx index f6853874..9f8865ea 100644 --- a/src/components/SaveCodeButton.tsx +++ b/src/components/SaveCodeButton.tsx @@ -40,7 +40,7 @@ export function SaveCodeButton({ type }: ISaveCodeButton) { const { sendToast } = useContext(ToastContext); const onSave = async () => { const codeFromActions = await getCodeFromActions(ipc, steps, type); - const exported = await ipc.callMain('save-file', codeFromActions); + const exported = await window.electronAPI.exportScript(codeFromActions); if (exported) { sendToast({ id: `file-export-${new Date().valueOf()}`, diff --git a/src/renderer.d.ts b/src/renderer.d.ts new file mode 100644 index 00000000..4978c9e0 --- /dev/null +++ b/src/renderer.d.ts @@ -0,0 +1,7 @@ +import type { IElectronAPI } from "../common/types" + +declare global { + interface Window { + electronAPI: IElectronAPI + } +} From 9500bbcca46dc9e05d4d4890eeb183ddd0344b7c Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Fri, 3 Feb 2023 02:53:49 +0900 Subject: [PATCH 02/14] chore: extract onRecordJourney --- common/types.d.ts | 7 ++ electron/api/recordJourney.ts | 134 ++++++++++++++++++++++++++++++ electron/execution.ts | 136 +++---------------------------- electron/preload.ts | 24 +++++- src/App.tsx | 10 +-- src/hooks/useRecordingContext.ts | 15 ++-- 6 files changed, 188 insertions(+), 138 deletions(-) create mode 100644 electron/api/recordJourney.ts diff --git a/common/types.d.ts b/common/types.d.ts index 5fd34893..d62db537 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -117,4 +117,11 @@ export type GenerateCodeOptions = { }; export interface IElectronAPI { exportScript: (string) => Promise, + /* <-- for recordJourney */ + recordJourney: (url: string) => Promise, + stopRecording: () => void, + pauseRecording: () => void, + resumeRecording: () => void, + onActionGenerated: (callback: (event: IpcRendererEvent, actions: ActionContext[]) => void) => (() => void), + /* for recordJourney --> */ } diff --git a/electron/api/recordJourney.ts b/electron/api/recordJourney.ts new file mode 100644 index 00000000..2d392ccf --- /dev/null +++ b/electron/api/recordJourney.ts @@ -0,0 +1,134 @@ +import { BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; +import type { BrowserContext } from 'playwright-core'; + +import path from 'path'; +import { EventEmitter, once } from 'events'; +import { existsSync } from 'fs'; +import { chromium } from 'playwright'; +import logger from 'electron-log'; +import { ActionInContext } from '../../common/types'; +import { EXECUTABLE_PATH } from '../config'; + +const IS_TEST_ENV = process.env.NODE_ENV === 'test'; +const CDP_TEST_PORT = parseInt(process.env.TEST_PORT ?? '61337') + 1; + +let browserContext: BrowserContext | null = null; +let actionListener = new EventEmitter(); + +export function onRecordJourneys(mainWindowEmitter: EventEmitter, isBrowserRunning: boolean) { + return async function (_event, url) { + const browserWindow = BrowserWindow.getFocusedWindow()!; + if (isBrowserRunning) { + throw new Error( + 'Cannot start recording a journey, a browser operation is already in progress.' + ); + } + mainWindowEmitter.emit('browser-started'); + try { + const { browser, context } = await launchContext(); + const closeBrowser = async () => { + browserContext = null; + actionListener.removeListener('actions', actionsHandler); + try { + await browser.close(); + } catch (e) { + logger.error('Browser close threw an error', e); + } + }; + ipcMain.addListener('stop-recording', closeBrowser); + // Listen to actions from Playwright recording session + const actionsHandler = (actions: ActionInContext[]) => { + // ipcMain.callRenderer(browserWindow, 'change', { actions }); + browserWindow.webContents.send('change', actions); + }; + browserContext = context; + actionListener = new EventEmitter(); + actionListener.on('actions', actionsHandler); + + const handleMainClose = () => { + actionListener.removeAllListeners(); + ipcMain.removeListener('stop-recording', closeBrowser); + browser.close().catch(() => { + // isBrowserRunning = false; + mainWindowEmitter.emit('browser-stopped'); + }); + }; + + mainWindowEmitter.addListener('main-close', handleMainClose); + + // _enableRecorder is private method, not defined in BrowserContext type + await (context as any)._enableRecorder({ + launchOptions: {}, + contextOptions: {}, + mode: 'recording', + showRecorder: false, + actionListener, + }); + await openPage(context, url); + await once(browser, 'disconnected'); + + mainWindowEmitter.removeListener('main-close', handleMainClose); + } catch (e) { + logger.error(e); + } finally { + // isBrowserRunning = false; + mainWindowEmitter.emit('browser-stopped'); + } + }; + } + + +async function launchContext() { + const browser = await chromium.launch({ + headless: IS_TEST_ENV, + executablePath: EXECUTABLE_PATH, + chromiumSandbox: true, + args: IS_TEST_ENV ? [`--remote-debugging-port=${CDP_TEST_PORT}`] : [], + }); + + const context = await browser.newContext(); + + let closingBrowser = false; + async function closeBrowser() { + if (closingBrowser) return; + closingBrowser = true; + await browser.close(); + } + + context.on('page', page => { + page.on('close', () => { + const hasPage = browser.contexts().some(context => context.pages().length > 0); + if (hasPage) return; + closeBrowser().catch(_e => null); + }); + }); + return { browser, context }; +} + +async function openPage(context: BrowserContext, url: string) { + const page = await context.newPage(); + if (url) { + if (existsSync(url)) url = 'file://' + path.resolve(url); + else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:')) + url = 'http://' + url; + await page.goto(url); + } + return page; + } + +// TODO: fix pause. it keeps recording +export async function onSetMode(_event: IpcMainInvokeEvent, mode: string) { + if (!browserContext) return; + const page = browserContext.pages()[0]; + if (!page) return; + await page.mainFrame().evaluate( + ([mode]) => { + // `_playwrightSetMode` is a private function + (window as any).__pw_setMode(mode); + }, + [mode] + ); + if (mode !== 'inspecting') return; + const [selector] = await once(actionListener, 'selector'); + return selector; + } diff --git a/electron/execution.ts b/electron/execution.ts index d65594e7..50750fcb 100644 --- a/electron/execution.ts +++ b/electron/execution.ts @@ -22,13 +22,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { chromium } from 'playwright'; import { join, resolve } from 'path'; -import { existsSync } from 'fs'; import { writeFile, rm, mkdir } from 'fs/promises'; import { ipcMain as ipc } from 'electron-better-ipc'; import { EventEmitter, once } from 'events'; -import { dialog, shell, BrowserWindow, ipcMain } from 'electron'; +import { dialog, shell, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; import { fork, ChildProcess } from 'child_process'; import logger from 'electron-log'; import isDev from 'electron-is-dev'; @@ -38,122 +36,25 @@ import type { ActionInContext, GenerateCodeOptions, RecorderSteps, - RecordJourneyOptions, RunJourneyOptions, StepEndEvent, StepStatus, TestEvent, } from '../common/types'; import { SyntheticsGenerator } from './syntheticsGenerator'; +import { onRecordJourneys, onSetMode } from './api/recordJourney'; const SYNTHETICS_CLI = require.resolve('@elastic/synthetics/dist/cli'); -const IS_TEST_ENV = process.env.NODE_ENV === 'test'; -const CDP_TEST_PORT = parseInt(process.env.TEST_PORT ?? '61337') + 1; +// TODO: setting isBrowserRunning from onRecordJourney is broken export enum MainWindowEvent { MAIN_CLOSE = 'main-close', + BROWSER_STARTED = 'browser-started', + BROWSER_STOPPED = 'browser-stopped', } -async function launchContext() { - const browser = await chromium.launch({ - headless: IS_TEST_ENV, - executablePath: EXECUTABLE_PATH, - chromiumSandbox: true, - args: IS_TEST_ENV ? [`--remote-debugging-port=${CDP_TEST_PORT}`] : [], - }); - - const context = await browser.newContext(); - - let closingBrowser = false; - async function closeBrowser() { - if (closingBrowser) return; - closingBrowser = true; - await browser.close(); - } - - context.on('page', page => { - page.on('close', () => { - const hasPage = browser.contexts().some(context => context.pages().length > 0); - if (hasPage) return; - closeBrowser().catch(_e => null); - }); - }); - return { browser, context }; -} - -async function openPage(context: BrowserContext, url: string) { - const page = await context.newPage(); - if (url) { - if (existsSync(url)) url = 'file://' + resolve(url); - else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:')) - url = 'http://' + url; - await page.goto(url); - } - return page; -} - -let browserContext: BrowserContext | null = null; -let actionListener = new EventEmitter(); let isBrowserRunning = false; -function onRecordJourneys(mainWindowEmitter: EventEmitter) { - return async function (data: { url: string }, browserWindow: BrowserWindow) { - if (isBrowserRunning) { - throw new Error( - 'Cannot start recording a journey, a browser operation is already in progress.' - ); - } - isBrowserRunning = true; - try { - const { browser, context } = await launchContext(); - const closeBrowser = async () => { - browserContext = null; - actionListener.removeListener('actions', actionsHandler); - try { - await browser.close(); - } catch (e) { - logger.error('Browser close threw an error', e); - } - }; - ipc.addListener('stop', closeBrowser); - // Listen to actions from Playwright recording session - const actionsHandler = (actions: ActionInContext[]) => { - ipc.callRenderer(browserWindow, 'change', { actions }); - }; - browserContext = context; - actionListener = new EventEmitter(); - actionListener.on('actions', actionsHandler); - - const handleMainClose = () => { - actionListener.removeAllListeners(); - ipc.removeListener('stop', closeBrowser); - browser.close().catch(() => { - isBrowserRunning = false; - }); - }; - - mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); - - // _enableRecorder is private method, not defined in BrowserContext type - await (context as any)._enableRecorder({ - launchOptions: {}, - contextOptions: {}, - mode: 'recording', - showRecorder: false, - actionListener, - }); - await openPage(context, data.url); - await once(browser, 'disconnected'); - - mainWindowEmitter.removeListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); - } catch (e) { - logger.error(e); - } finally { - isBrowserRunning = false; - } - }; -} - /** * Attempts to find the step associated with a `step/end` event. * @@ -351,7 +252,7 @@ function onTest(mainWindowEmitter: EventEmitter) { }; } -async function onFileSave(_event, code: string) { +async function onExportScript(_event, code: string) { const window = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; const { filePath, canceled } = await dialog.showSaveDialog(window, { filters: [ @@ -375,21 +276,6 @@ async function onGenerateCode(data: { isProject: boolean; actions: RecorderSteps return generator.generateFromSteps(data.actions); } -async function onSetMode(mode: string) { - if (!browserContext) return; - const page = browserContext.pages()[0]; - if (!page) return; - await page.mainFrame().evaluate( - ([mode]) => { - // `_playwrightSetMode` is a private function - (window as any).__pw_setMode(mode); - }, - [mode] - ); - if (mode !== 'inspecting') return; - const [selector] = await once(actionListener, 'selector'); - return selector; -} async function onLinkExternal(url: string) { try { @@ -409,13 +295,15 @@ async function onLinkExternal(url: string) { * is destroyed or they will leak/block the next window from interacting with top-level app state. */ export default function setupListeners(mainWindowEmitter: EventEmitter) { - ipcMain.handle('export-script', onFileSave); + ipcMain.handle('record-journey', onRecordJourneys(mainWindowEmitter, isBrowserRunning)) + ipcMain.handle('export-script', onExportScript); + ipcMain.handle('set-mode', onSetMode) return [ - ipc.answerRenderer('record-journey', onRecordJourneys(mainWindowEmitter)), + // ipc.answerRenderer('record-journey', onRecordJourneys(mainWindowEmitter)), ipc.answerRenderer('run-journey', onTest(mainWindowEmitter)), ipc.answerRenderer('actions-to-code', onGenerateCode), - // ipc.answerRenderer('save-file', onFileSave), - ipc.answerRenderer('set-mode', onSetMode), + // ipc.answerRenderer('save-file', onSaveFile), + // ipc.answerRenderer('set-mode', onSetMode), ipc.answerRenderer('link-to-external', onLinkExternal), ]; } diff --git a/electron/preload.ts b/electron/preload.ts index 28243a9c..f7b38be0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,10 +1,26 @@ -import type { IElectronAPI } from '../common/types'; -import { contextBridge, ipcRenderer } from 'electron'; +import type { ActionContext, IElectronAPI } from '../common/types'; +import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; const electronAPI: IElectronAPI = { - exportScript: async (contents) => { + exportScript: async (contents) => { return await ipcRenderer.invoke('export-script', contents); - } + }, + recordJourney: async (url) => { + return await ipcRenderer.invoke('record-journey', url); + }, + stopRecording: () => { + ipcRenderer.send('stop-recording'); + }, + pauseRecording: () => { + ipcRenderer.send('set-mode', 'none'); + }, + resumeRecording: () => { + ipcRenderer.send('set-mode', 'recording'); + }, + onActionGenerated: (callback: (_event: IpcRendererEvent, actions: ActionContext[]) => void): (() => void) => { + ipcRenderer.on('change', callback); + return () => { ipcRenderer.removeAllListeners('change') } + }, } contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/App.tsx b/src/App.tsx index d408b2c8..e6a032d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,7 @@ import { ExportScriptFlyout } from './components/ExportScriptFlyout'; import { useRecordingContext } from './hooks/useRecordingContext'; import { StartOverWarningModal } from './components/StartOverWarningModal'; import { ActionContext, RecorderSteps, Steps } from '../common/types'; +import type { IpcRendererEvent } from 'electron'; /** * This is the prescribed workaround to some internal EUI issues that occur @@ -84,16 +85,15 @@ export default function App() { useEffect(() => { // `actions` here is a set of `ActionInContext`s that make up a `Step` - const listener = ({ actions }: { actions: ActionContext[] }) => { + const listener = (_event: IpcRendererEvent, actions: ActionContext[]) => { setSteps((prevSteps: RecorderSteps) => { const nextSteps: Steps = generateIR([{ actions }]); return generateMergedIR(prevSteps, nextSteps); }); }; - ipc.answerMain('change', listener); - return () => { - ipc.removeListener('change', listener); - }; + // ipc.answerMain('change', listener); + const removeListener = window.electronAPI.onActionGenerated(listener); + return removeListener; }, [ipc, setSteps]); return ( diff --git a/src/hooks/useRecordingContext.ts b/src/hooks/useRecordingContext.ts index 792e3bcf..729f80ff 100644 --- a/src/hooks/useRecordingContext.ts +++ b/src/hooks/useRecordingContext.ts @@ -51,10 +51,12 @@ export function useRecordingContext( } else if (recordingStatus === RecordingStatus.Recording) { setRecordingStatus(RecordingStatus.NotRecording); // Stop browser process - ipc.send('stop'); + // ipc.send('stop'); + window.electronAPI.stopRecording(); } else { setRecordingStatus(RecordingStatus.Recording); - await ipc.callMain('record-journey', { url }); + // await ipc.callMain('record-journey', { url }); + await window.electronAPI.recordJourney(url); setRecordingStatus(RecordingStatus.NotRecording); } }, [ipc, recordingStatus, stepCount, url]); @@ -66,7 +68,8 @@ export function useRecordingContext( // Depends on the result's context, because when we overwrite // a previous journey we need to discard its result status setResult(undefined); - await ipc.callMain('record-journey', { url }); + // await ipc.callMain('record-journey', { url }); + await window.electronAPI.recordJourney(url); setRecordingStatus(RecordingStatus.NotRecording); } }, [ipc, recordingStatus, setResult, setSteps, url]); @@ -75,9 +78,11 @@ export function useRecordingContext( if (recordingStatus === RecordingStatus.NotRecording) return; if (recordingStatus !== RecordingStatus.Paused) { setRecordingStatus(RecordingStatus.Paused); - await ipc.callMain('set-mode', 'none'); + // await ipc.callMain('set-mode', 'none'); + await window.electronAPI.pauseRecording(); } else { - await ipc.callMain('set-mode', 'recording'); + // await ipc.callMain('set-mode', 'recording'); + await window.electronAPI.resumeRecording(); setRecordingStatus(RecordingStatus.Recording); } }; From 310c116df39f9b8b02c1906a286902ca6663e0eb Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Mon, 6 Feb 2023 16:49:30 +0900 Subject: [PATCH 03/14] chore: separate functions by injecting deps --- .eslintrc.js | 3 + common/types.d.ts | 14 +- electron/api/index.ts | 25 +++ electron/api/recordJourney.ts | 184 +++++++----------- electron/api/setMode.ts | 45 +++++ electron/browserManager.ts | 87 +++++++++ electron/electron.ts | 2 +- electron/execution.ts | 23 ++- electron/preload.ts | 67 +++++-- src/components/TestResult/TestResult.test.tsx | 1 - src/contexts/CommunicationContext.ts | 16 +- src/helpers/test/defaults.ts | 1 - src/renderer.d.ts | 27 ++- 13 files changed, 332 insertions(+), 163 deletions(-) create mode 100644 electron/api/index.ts create mode 100644 electron/api/setMode.ts create mode 100644 electron/browserManager.ts diff --git a/.eslintrc.js b/.eslintrc.js index c8229e37..224d4fd6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,6 +50,9 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-non-null-assertion': 0, '@typescript-eslint/no-explicit-any': 0, + // temporary mute + '@typescript-eslint/ban-ts-comment': 0, + '@typescript-eslint/no-empty-function': 0, }, }, ], diff --git a/common/types.d.ts b/common/types.d.ts index d62db537..de9597ac 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -116,12 +116,14 @@ export type GenerateCodeOptions = { isProject: boolean; }; export interface IElectronAPI { - exportScript: (string) => Promise, + exportScript: (string) => Promise; /* <-- for recordJourney */ - recordJourney: (url: string) => Promise, - stopRecording: () => void, - pauseRecording: () => void, - resumeRecording: () => void, - onActionGenerated: (callback: (event: IpcRendererEvent, actions: ActionContext[]) => void) => (() => void), + recordJourney: (url: string) => Promise; + stopRecording: () => void; + pauseRecording: () => Promise; + resumeRecording: () => Promise; + onActionGenerated: ( + callback: (event: IpcRendererEvent, actions: ActionContext[]) => void + ) => () => void; /* for recordJourney --> */ } diff --git a/electron/api/index.ts b/electron/api/index.ts new file mode 100644 index 00000000..6d1d98bf --- /dev/null +++ b/electron/api/index.ts @@ -0,0 +1,25 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +export * from './recordJourney'; +export * from './setMode'; diff --git a/electron/api/recordJourney.ts b/electron/api/recordJourney.ts index 2d392ccf..e183079e 100644 --- a/electron/api/recordJourney.ts +++ b/electron/api/recordJourney.ts @@ -1,134 +1,86 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ import { BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; import type { BrowserContext } from 'playwright-core'; import path from 'path'; import { EventEmitter, once } from 'events'; import { existsSync } from 'fs'; -import { chromium } from 'playwright'; import logger from 'electron-log'; import { ActionInContext } from '../../common/types'; -import { EXECUTABLE_PATH } from '../config'; +import { BrowserManager } from '../browserManager'; -const IS_TEST_ENV = process.env.NODE_ENV === 'test'; -const CDP_TEST_PORT = parseInt(process.env.TEST_PORT ?? '61337') + 1; - -let browserContext: BrowserContext | null = null; -let actionListener = new EventEmitter(); - -export function onRecordJourneys(mainWindowEmitter: EventEmitter, isBrowserRunning: boolean) { - return async function (_event, url) { +export function onRecordJourneys(browserManager: BrowserManager) { + return async function (_event: IpcMainInvokeEvent, url: string) { const browserWindow = BrowserWindow.getFocusedWindow()!; - if (isBrowserRunning) { - throw new Error( - 'Cannot start recording a journey, a browser operation is already in progress.' - ); - } - mainWindowEmitter.emit('browser-started'); - try { - const { browser, context } = await launchContext(); - const closeBrowser = async () => { - browserContext = null; - actionListener.removeListener('actions', actionsHandler); - try { - await browser.close(); - } catch (e) { - logger.error('Browser close threw an error', e); - } - }; - ipcMain.addListener('stop-recording', closeBrowser); - // Listen to actions from Playwright recording session - const actionsHandler = (actions: ActionInContext[]) => { - // ipcMain.callRenderer(browserWindow, 'change', { actions }); - browserWindow.webContents.send('change', actions); - }; - browserContext = context; - actionListener = new EventEmitter(); - actionListener.on('actions', actionsHandler); - - const handleMainClose = () => { - actionListener.removeAllListeners(); - ipcMain.removeListener('stop-recording', closeBrowser); - browser.close().catch(() => { - // isBrowserRunning = false; - mainWindowEmitter.emit('browser-stopped'); - }); - }; - - mainWindowEmitter.addListener('main-close', handleMainClose); - - // _enableRecorder is private method, not defined in BrowserContext type - await (context as any)._enableRecorder({ - launchOptions: {}, - contextOptions: {}, - mode: 'recording', - showRecorder: false, - actionListener, - }); - await openPage(context, url); - await once(browser, 'disconnected'); - - mainWindowEmitter.removeListener('main-close', handleMainClose); - } catch (e) { - logger.error(e); - } finally { - // isBrowserRunning = false; - mainWindowEmitter.emit('browser-stopped'); - } - }; - } - + if (browserManager.isRunning()) { + throw new Error( + 'Cannot start recording a journey, a browser operation is already in progress.' + ); + } -async function launchContext() { - const browser = await chromium.launch({ - headless: IS_TEST_ENV, - executablePath: EXECUTABLE_PATH, - chromiumSandbox: true, - args: IS_TEST_ENV ? [`--remote-debugging-port=${CDP_TEST_PORT}`] : [], - }); + try { + const { browser, context } = await browserManager.launchBrowser(); + const actionListener = new EventEmitter(); - const context = await browser.newContext(); + ipcMain.handleOnce('stop-recording', async () => { + actionListener.removeListener('actions', actionsHandler); + await browserManager.closeBrowser(); + }); - let closingBrowser = false; - async function closeBrowser() { - if (closingBrowser) return; - closingBrowser = true; - await browser.close(); - } + // Listen to actions from Playwright recording session + const actionsHandler = (actions: ActionInContext[]) => { + // ipcMain.callRenderer(browserWindow, 'change', { actions }); + browserWindow.webContents.send('change', actions); + }; + actionListener.on('actions', actionsHandler); - context.on('page', page => { - page.on('close', () => { - const hasPage = browser.contexts().some(context => context.pages().length > 0); - if (hasPage) return; - closeBrowser().catch(_e => null); - }); - }); - return { browser, context }; + // _enableRecorder is private method, not defined in BrowserContext type + await (context as any)._enableRecorder({ + launchOptions: {}, + contextOptions: {}, + mode: 'recording', + showRecorder: false, + actionListener, + }); + await openPage(context, url); + await once(browser, 'disconnected'); + } catch (e) { + logger.error(e); + } finally { + ipcMain.removeHandler('stop-recording'); + } + }; } async function openPage(context: BrowserContext, url: string) { - const page = await context.newPage(); - if (url) { - if (existsSync(url)) url = 'file://' + path.resolve(url); - else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:')) - url = 'http://' + url; - await page.goto(url); - } - return page; - } - -// TODO: fix pause. it keeps recording -export async function onSetMode(_event: IpcMainInvokeEvent, mode: string) { - if (!browserContext) return; - const page = browserContext.pages()[0]; - if (!page) return; - await page.mainFrame().evaluate( - ([mode]) => { - // `_playwrightSetMode` is a private function - (window as any).__pw_setMode(mode); - }, - [mode] - ); - if (mode !== 'inspecting') return; - const [selector] = await once(actionListener, 'selector'); - return selector; + const page = await context.newPage(); + if (url) { + if (existsSync(url)) url = 'file://' + path.resolve(url); + else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:')) + url = 'http://' + url; + await page.goto(url); } + return page; +} diff --git a/electron/api/setMode.ts b/electron/api/setMode.ts new file mode 100644 index 00000000..77c2a09a --- /dev/null +++ b/electron/api/setMode.ts @@ -0,0 +1,45 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import { IpcMainInvokeEvent } from 'electron'; +import { BrowserManager } from '../browserManager'; + +export function onSetMode(browserManager: BrowserManager) { + return async function (_event: IpcMainInvokeEvent, mode: string) { + const browserContext = browserManager.getContext(); + if (!browserContext) return; + const page = browserContext.pages()[0]; + if (!page) return; + await page.mainFrame().evaluate( + ([mode]) => { + // `__pw_setMode` is a private function + (window as any).__pw_setMode(mode); + }, + [mode] + ); + if (mode !== 'inspecting') return; + // TODO: see if deleting code below doesn't have any affects + // const [selector] = await once(actionListener, 'selector'); + // return selector; + }; +} diff --git a/electron/browserManager.ts b/electron/browserManager.ts new file mode 100644 index 00000000..fc6c7a3a --- /dev/null +++ b/electron/browserManager.ts @@ -0,0 +1,87 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import { chromium, Browser, BrowserContext } from 'playwright'; +import logger from 'electron-log'; +import { EXECUTABLE_PATH } from './config'; + +const IS_TEST_ENV = process.env.NODE_ENV === 'test'; +const CDP_TEST_PORT = parseInt(process.env.TEST_PORT ?? '61337') + 1; + +export class BrowserManager { + protected _closingBrowser = false; + protected _browser: Browser | null = null; + protected _context: BrowserContext | null = null; + + isRunning() { + return this._browser != null; + } + + getContext() { + return this._context; + } + + onBrowserClosed() { + this._browser = null; + this._context = null; + this._closingBrowser = false; + } + + async launchBrowser() { + const browser = await chromium.launch({ + headless: IS_TEST_ENV, + executablePath: EXECUTABLE_PATH, + chromiumSandbox: true, + args: IS_TEST_ENV ? [`--remote-debugging-port=${CDP_TEST_PORT}`] : [], + }); + + const context = await browser.newContext(); + this._browser = browser; + this._context = context; + + context.on('page', page => { + page.on('close', async () => { + const hasPage = browser.contexts().some(context => context.pages().length > 0); + if (hasPage) { + console.log('it has page'); + return; + } + await this.closeBrowser(); + }); + }); + return { browser, context }; + } + + async closeBrowser() { + if (this._browser == null || this._closingBrowser) return; + this._closingBrowser = true; + try { + await this._browser.close(); + this.onBrowserClosed(); + } catch (e) { + logger.error('Browser close threw an error', e); + } + } +} + +export const browserManager = new BrowserManager(); diff --git a/electron/electron.ts b/electron/electron.ts index b8c4fd64..1753c13c 100644 --- a/electron/electron.ts +++ b/electron/electron.ts @@ -51,7 +51,7 @@ async function createWindow() { minWidth: 800, webPreferences: { devTools: isDev || IS_TEST, - preload: path.join(__dirname, 'preload.js') + preload: path.join(__dirname, 'preload.js'), }, }); diff --git a/electron/execution.ts b/electron/execution.ts index 50750fcb..11a9c33a 100644 --- a/electron/execution.ts +++ b/electron/execution.ts @@ -22,16 +22,15 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { join, resolve } from 'path'; +import { join } from 'path'; import { writeFile, rm, mkdir } from 'fs/promises'; import { ipcMain as ipc } from 'electron-better-ipc'; -import { EventEmitter, once } from 'events'; +import { EventEmitter } from 'events'; import { dialog, shell, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; import { fork, ChildProcess } from 'child_process'; import logger from 'electron-log'; import isDev from 'electron-is-dev'; import { JOURNEY_DIR, PLAYWRIGHT_BROWSERS_PATH, EXECUTABLE_PATH } from './config'; -import type { BrowserContext } from 'playwright-core'; import type { ActionInContext, GenerateCodeOptions, @@ -42,15 +41,14 @@ import type { TestEvent, } from '../common/types'; import { SyntheticsGenerator } from './syntheticsGenerator'; -import { onRecordJourneys, onSetMode } from './api/recordJourney'; +import { browserManager } from './browserManager'; +import { onRecordJourneys, onSetMode } from './api'; const SYNTHETICS_CLI = require.resolve('@elastic/synthetics/dist/cli'); // TODO: setting isBrowserRunning from onRecordJourney is broken export enum MainWindowEvent { MAIN_CLOSE = 'main-close', - BROWSER_STARTED = 'browser-started', - BROWSER_STOPPED = 'browser-stopped', } let isBrowserRunning = false; @@ -252,7 +250,7 @@ function onTest(mainWindowEmitter: EventEmitter) { }; } -async function onExportScript(_event, code: string) { +async function onExportScript(_event: IpcMainInvokeEvent, code: string) { const window = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; const { filePath, canceled } = await dialog.showSaveDialog(window, { filters: [ @@ -276,7 +274,6 @@ async function onGenerateCode(data: { isProject: boolean; actions: RecorderSteps return generator.generateFromSteps(data.actions); } - async function onLinkExternal(url: string) { try { await shell.openExternal(url); @@ -295,9 +292,15 @@ async function onLinkExternal(url: string) { * is destroyed or they will leak/block the next window from interacting with top-level app state. */ export default function setupListeners(mainWindowEmitter: EventEmitter) { - ipcMain.handle('record-journey', onRecordJourneys(mainWindowEmitter, isBrowserRunning)) + mainWindowEmitter.once(MainWindowEvent.MAIN_CLOSE, async () => { + if (browserManager.isRunning()) { + await browserManager.closeBrowser(); + } + }); + + ipcMain.handle('record-journey', onRecordJourneys(browserManager)); ipcMain.handle('export-script', onExportScript); - ipcMain.handle('set-mode', onSetMode) + ipcMain.handle('set-mode', onSetMode(browserManager)); return [ // ipc.answerRenderer('record-journey', onRecordJourneys(mainWindowEmitter)), ipc.answerRenderer('run-journey', onTest(mainWindowEmitter)), diff --git a/electron/preload.ts b/electron/preload.ts index f7b38be0..031f8d1a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,26 +1,53 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ import type { ActionContext, IElectronAPI } from '../common/types'; import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; const electronAPI: IElectronAPI = { - exportScript: async (contents) => { - return await ipcRenderer.invoke('export-script', contents); - }, - recordJourney: async (url) => { - return await ipcRenderer.invoke('record-journey', url); - }, - stopRecording: () => { - ipcRenderer.send('stop-recording'); - }, - pauseRecording: () => { - ipcRenderer.send('set-mode', 'none'); - }, - resumeRecording: () => { - ipcRenderer.send('set-mode', 'recording'); - }, - onActionGenerated: (callback: (_event: IpcRendererEvent, actions: ActionContext[]) => void): (() => void) => { - ipcRenderer.on('change', callback); - return () => { ipcRenderer.removeAllListeners('change') } - }, -} + exportScript: async contents => { + return await ipcRenderer.invoke('export-script', contents); + }, + recordJourney: async url => { + return await ipcRenderer.invoke('record-journey', url); + }, + stopRecording: async () => { + await ipcRenderer.invoke('stop-recording'); + }, + pauseRecording: async () => { + await ipcRenderer.invoke('set-mode', 'none'); + }, + resumeRecording: async () => { + await ipcRenderer.invoke('set-mode', 'recording'); + }, + onActionGenerated: ( + callback: (_event: IpcRendererEvent, actions: ActionContext[]) => void + ): (() => void) => { + ipcRenderer.on('change', callback); + return () => { + ipcRenderer.removeAllListeners('change'); + }; + }, +}; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/components/TestResult/TestResult.test.tsx b/src/components/TestResult/TestResult.test.tsx index f6b07dfe..e70e24d1 100644 --- a/src/components/TestResult/TestResult.test.tsx +++ b/src/components/TestResult/TestResult.test.tsx @@ -170,7 +170,6 @@ step('Click save', () => { }); const { getByText, queryByText } = render(, { contextOverrides: { - // @ts-expect-error partial implementation for testing communication: { ipc: { callMain } }, steps: { steps: [createStep(['Click save'])] }, test: { diff --git a/src/contexts/CommunicationContext.ts b/src/contexts/CommunicationContext.ts index fb547ae7..af2a343b 100644 --- a/src/contexts/CommunicationContext.ts +++ b/src/contexts/CommunicationContext.ts @@ -21,16 +21,20 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { RendererProcessIpc } from 'electron-better-ipc'; +// @ts-nocheck import { createContext } from 'react'; -const { ipcRenderer } = window.require('electron-better-ipc'); +// const { ipcRenderer } = window.require('electron-better-ipc'); export interface ICommunicationContext { - ipc: RendererProcessIpc; + ipc: any; } - export const CommunicationContext = createContext({ - ipc: ipcRenderer, + ipc: { + callMain: () => {}, + send: () => {}, + on: () => {}, + removeListener(channel: any, listener: any) {}, + answerMain: () => {}, + }, }); diff --git a/src/helpers/test/defaults.ts b/src/helpers/test/defaults.ts index 53d15afd..c37b3436 100644 --- a/src/helpers/test/defaults.ts +++ b/src/helpers/test/defaults.ts @@ -82,7 +82,6 @@ export const getToastContextDefaults = (): IToastContext => ({ }); export const getCommunicationContextDefaults = (): ICommunicationContext => ({ - // @ts-expect-error partial implementation for testing ipc: { answerMain: jest.fn(), callMain: jest.fn(), diff --git a/src/renderer.d.ts b/src/renderer.d.ts index 4978c9e0..a645d163 100644 --- a/src/renderer.d.ts +++ b/src/renderer.d.ts @@ -1,7 +1,30 @@ -import type { IElectronAPI } from "../common/types" +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import type { IElectronAPI } from '../common/types'; declare global { interface Window { - electronAPI: IElectronAPI + electronAPI: IElectronAPI; } } From 83f63c198959e8330897c54fc09563a6953598ec Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Tue, 7 Feb 2023 02:41:23 +0900 Subject: [PATCH 04/14] chore: fix tests, replace ipc in renderer process --- common/types.d.ts | 8 +++ electron/api/exportScript.ts | 44 ++++++++++++ electron/api/index.ts | 1 + electron/browserManager.ts | 1 - electron/electron.ts | 8 +-- electron/execution.ts | 70 +++++++------------ electron/preload.ts | 25 ++++++- src/App.tsx | 8 +-- src/common/shared.test.ts | 22 +++--- src/common/shared.ts | 15 ++-- src/components/Assertion/AssertionInfo.tsx | 4 +- src/components/ExportScriptFlyout/Flyout.tsx | 6 +- src/components/Header/Title.tsx | 4 +- src/components/SaveCodeButton.test.tsx | 13 ++-- src/components/SaveCodeButton.tsx | 6 +- src/components/TestResult/TestResult.test.tsx | 7 +- src/components/TestResult/TestResult.tsx | 6 +- src/contexts/CommunicationContext.ts | 14 ++-- src/helpers/test/defaults.ts | 15 ++-- src/helpers/test/ipc.ts | 37 ++++------ src/hooks/useRecordingContext.test.ts | 36 +++++----- src/hooks/useRecordingContext.ts | 19 +++-- src/hooks/useSyntheticsTest.test.tsx | 14 ++-- src/hooks/useSyntheticsTest.ts | 27 +++---- 24 files changed, 223 insertions(+), 187 deletions(-) create mode 100644 electron/api/exportScript.ts diff --git a/common/types.d.ts b/common/types.d.ts index de9597ac..19e57fb2 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -126,4 +126,12 @@ export interface IElectronAPI { callback: (event: IpcRendererEvent, actions: ActionContext[]) => void ) => () => void; /* for recordJourney --> */ + generateCode: (params: GenerateCodeOptions) => Promise; + openExternalLink: (url: string) => Promise; + + runTest: ( + params: RunJourneyOptions, + callback: (_event: IpcRendererEvent, data: TestEvent) => void + ) => Promise; + removeOnTestListener: () => void; } diff --git a/electron/api/exportScript.ts b/electron/api/exportScript.ts new file mode 100644 index 00000000..924c021f --- /dev/null +++ b/electron/api/exportScript.ts @@ -0,0 +1,44 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import { BrowserWindow, dialog, IpcMainInvokeEvent } from 'electron'; +import { writeFile } from 'fs/promises'; + +export async function onExportScript(_event: IpcMainInvokeEvent, code: string) { + const window = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; + const { filePath, canceled } = await dialog.showSaveDialog(window, { + filters: [ + { + name: 'JavaScript', + extensions: ['js'], + }, + ], + defaultPath: 'recorded.journey.js', + }); + + if (!canceled && filePath) { + await writeFile(filePath, code); + return true; + } + return false; +} diff --git a/electron/api/index.ts b/electron/api/index.ts index 6d1d98bf..8706a273 100644 --- a/electron/api/index.ts +++ b/electron/api/index.ts @@ -23,3 +23,4 @@ THE SOFTWARE. */ export * from './recordJourney'; export * from './setMode'; +export * from './exportScript'; diff --git a/electron/browserManager.ts b/electron/browserManager.ts index fc6c7a3a..361d872e 100644 --- a/electron/browserManager.ts +++ b/electron/browserManager.ts @@ -63,7 +63,6 @@ export class BrowserManager { page.on('close', async () => { const hasPage = browser.contexts().some(context => context.pages().length > 0); if (hasPage) { - console.log('it has page'); return; } await this.closeBrowser(); diff --git a/electron/electron.ts b/electron/electron.ts index 1753c13c..431265f1 100644 --- a/electron/electron.ts +++ b/electron/electron.ts @@ -23,7 +23,7 @@ THE SOFTWARE. */ import path from 'path'; -import { app, BrowserWindow, ipcMain, Menu } from 'electron'; +import { app, BrowserWindow, Menu } from 'electron'; import isDev from 'electron-is-dev'; import unhandled from 'electron-unhandled'; import debug from 'electron-debug'; @@ -85,10 +85,8 @@ function createMenu() { async function createMainWindow() { if (BrowserWindow.getAllWindows().length === 0) { const mainWindowEmitter = await createWindow(); - const ipcListenerDestructors = setupListeners(mainWindowEmitter); - mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, () => - ipcListenerDestructors.forEach(f => f()) - ); + const ipcListenerDestructor = setupListeners(mainWindowEmitter); + mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, () => ipcListenerDestructor()); createMenu(); } } diff --git a/electron/execution.ts b/electron/execution.ts index 11a9c33a..de9e1dd6 100644 --- a/electron/execution.ts +++ b/electron/execution.ts @@ -24,16 +24,14 @@ THE SOFTWARE. import { join } from 'path'; import { writeFile, rm, mkdir } from 'fs/promises'; -import { ipcMain as ipc } from 'electron-better-ipc'; import { EventEmitter } from 'events'; -import { dialog, shell, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; +import { shell, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; import { fork, ChildProcess } from 'child_process'; import logger from 'electron-log'; import isDev from 'electron-is-dev'; -import { JOURNEY_DIR, PLAYWRIGHT_BROWSERS_PATH, EXECUTABLE_PATH } from './config'; +import { JOURNEY_DIR, PLAYWRIGHT_BROWSERS_PATH } from './config'; import type { ActionInContext, - GenerateCodeOptions, RecorderSteps, RunJourneyOptions, StepEndEvent, @@ -41,8 +39,8 @@ import type { TestEvent, } from '../common/types'; import { SyntheticsGenerator } from './syntheticsGenerator'; -import { browserManager } from './browserManager'; -import { onRecordJourneys, onSetMode } from './api'; +import { BrowserManager, browserManager } from './browserManager'; +import { onRecordJourneys, onSetMode, onExportScript } from './api'; const SYNTHETICS_CLI = require.resolve('@elastic/synthetics/dist/cli'); @@ -51,8 +49,6 @@ export enum MainWindowEvent { MAIN_CLOSE = 'main-close', } -let isBrowserRunning = false; - /** * Attempts to find the step associated with a `step/end` event. * @@ -81,14 +77,15 @@ function addActionsToStepResult(steps: RecorderSteps, event: StepEndEvent): Test }; } -function onTest(mainWindowEmitter: EventEmitter) { - return async function (data: RunJourneyOptions, browserWindow: BrowserWindow) { - if (isBrowserRunning) { +function onTest(browserManager: BrowserManager) { + return async function (_event: IpcMainInvokeEvent, data: RunJourneyOptions) { + if (browserManager.isRunning()) { throw new Error( 'Cannot start testing a journey, a browser operation is already in progress.' ); } - isBrowserRunning = true; + // TODO: connect onTest with browserManager and refactor + // browserManager.isRunning() = true; const parseOrSkip = (chunk: string): Array> => { // at times stdout ships multiple steps in one chunk, broken by newline, // so here we split on the newline @@ -156,7 +153,8 @@ function onTest(mainWindowEmitter: EventEmitter) { } return null; }; - + // TODO: de-deup browserWindow getter + const browserWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; const sendTestEvent = (event: TestEvent) => { browserWindow.webContents.send('test-event', event); }; @@ -210,7 +208,7 @@ function onTest(mainWindowEmitter: EventEmitter) { logger.warn('Unable to abort Synthetics test proceess.'); } } - mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); + // mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); const { stdout, stdin, stderr } = synthCliProcess as ChildProcess; if (!isProject) { @@ -229,7 +227,7 @@ function onTest(mainWindowEmitter: EventEmitter) { await rm(filePath, { recursive: true, force: true }); } - mainWindowEmitter.removeListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); + // mainWindowEmitter.removeListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); } catch (error: unknown) { logger.error(error); sendTestEvent({ @@ -245,36 +243,20 @@ function onTest(mainWindowEmitter: EventEmitter) { `Attempted to send SIGTERM to synthetics process, but did not receive exit signal. Process ID is ${synthCliProcess.pid}.` ); } - isBrowserRunning = false; + // isBrowserRunning = false; } }; } -async function onExportScript(_event: IpcMainInvokeEvent, code: string) { - const window = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; - const { filePath, canceled } = await dialog.showSaveDialog(window, { - filters: [ - { - name: 'JavaScript', - extensions: ['js'], - }, - ], - defaultPath: 'recorded.journey.js', - }); - - if (!canceled && filePath) { - await writeFile(filePath, code); - return true; - } - return false; -} - -async function onGenerateCode(data: { isProject: boolean; actions: RecorderSteps }) { +async function onGenerateCode( + _event: IpcMainInvokeEvent, + data: { isProject: boolean; actions: RecorderSteps } +) { const generator = new SyntheticsGenerator(data.isProject); return generator.generateFromSteps(data.actions); } -async function onLinkExternal(url: string) { +async function onLinkExternal(_event: IpcMainInvokeEvent, url: string) { try { await shell.openExternal(url); } catch (e) { @@ -297,16 +279,12 @@ export default function setupListeners(mainWindowEmitter: EventEmitter) { await browserManager.closeBrowser(); } }); - ipcMain.handle('record-journey', onRecordJourneys(browserManager)); + ipcMain.handle('run-journey', onTest(browserManager)); + ipcMain.handle('actions-to-code', onGenerateCode); ipcMain.handle('export-script', onExportScript); ipcMain.handle('set-mode', onSetMode(browserManager)); - return [ - // ipc.answerRenderer('record-journey', onRecordJourneys(mainWindowEmitter)), - ipc.answerRenderer('run-journey', onTest(mainWindowEmitter)), - ipc.answerRenderer('actions-to-code', onGenerateCode), - // ipc.answerRenderer('save-file', onSaveFile), - // ipc.answerRenderer('set-mode', onSetMode), - ipc.answerRenderer('link-to-external', onLinkExternal), - ]; + ipcMain.handle('link-to-external', onLinkExternal); + + return () => ipcMain.removeAllListeners(); } diff --git a/electron/preload.ts b/electron/preload.ts index 031f8d1a..02d02bcc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -21,7 +21,13 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { ActionContext, IElectronAPI } from '../common/types'; +import type { + ActionContext, + GenerateCodeOptions, + IElectronAPI, + RunJourneyOptions, + TestEvent, +} from '../common/types'; import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; const electronAPI: IElectronAPI = { @@ -48,6 +54,23 @@ const electronAPI: IElectronAPI = { ipcRenderer.removeAllListeners('change'); }; }, + generateCode: async (params: GenerateCodeOptions) => { + return ipcRenderer.invoke('actions-to-code', params); + }, + openExternalLink: async (url: string) => { + await ipcRenderer.invoke('link-to-external', url); + }, + runTest: async ( + params: RunJourneyOptions, + callback: (_event: IpcRendererEvent, data: TestEvent) => void + ) => { + ipcRenderer.on('test-event', callback); + await ipcRenderer.invoke('run-journey', params); + }, + + removeOnTestListener: () => { + ipcRenderer.removeAllListeners('test-event'); + }, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/App.tsx b/src/App.tsx index e6a032d7..857de040 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,12 +66,12 @@ export default function App() { const [url, setUrl] = useState(''); const [isCodeFlyoutVisible, setIsCodeFlyoutVisible] = useState(false); - const { ipc } = useContext(CommunicationContext); + const { electronAPI } = useContext(CommunicationContext); const stepsContextUtils = useStepsContext(); const { steps, setSteps } = stepsContextUtils; const syntheticsTestUtils = useSyntheticsTest(steps); const recordingContextUtils = useRecordingContext( - ipc, + electronAPI, url, steps.length, syntheticsTestUtils.setResult, @@ -92,9 +92,9 @@ export default function App() { }); }; // ipc.answerMain('change', listener); - const removeListener = window.electronAPI.onActionGenerated(listener); + const removeListener = electronAPI.onActionGenerated(listener); return removeListener; - }, [ipc, setSteps]); + }, [electronAPI, setSteps]); return ( diff --git a/src/common/shared.test.ts b/src/common/shared.test.ts index 707ed4ef..8e213436 100644 --- a/src/common/shared.test.ts +++ b/src/common/shared.test.ts @@ -23,29 +23,27 @@ THE SOFTWARE. */ // import type { Step } from '@elastic/synthetics'; -import type { Step } from '../../common/types'; -import { RendererProcessIpc } from 'electron-better-ipc'; +import type { IElectronAPI, Step } from '../../common/types'; import { getCodeForFailedResult } from './shared'; describe('shared', () => { describe('getCodeForFailedResult', () => { - let mockIpc: RendererProcessIpc; + let mockApi: IElectronAPI; beforeEach(() => { const mock = { - answerMain: jest.fn(), - callMain: jest.fn(), + generateCode: jest.fn(), }; - mockIpc = mock as unknown as RendererProcessIpc; + mockApi = mock as unknown as IElectronAPI; }); it('returns empty string for undefined journey', async () => { - expect(await getCodeForFailedResult(mockIpc, [])).toBe(''); + expect(await getCodeForFailedResult(mockApi, [])).toBe(''); }); it('returns an empty string if there are no failed steps in the journey', async () => { expect( - await getCodeForFailedResult(mockIpc, [], { + await getCodeForFailedResult(mockApi, [], { status: 'succeeded', type: 'inline', steps: [ @@ -61,7 +59,7 @@ describe('shared', () => { it('returns an empty string if there is no step title matching journey name', async () => { expect( - await getCodeForFailedResult(mockIpc, [], { + await getCodeForFailedResult(mockApi, [], { status: 'failed', type: 'inline', steps: [ @@ -93,7 +91,7 @@ describe('shared', () => { ], }; - await getCodeForFailedResult(mockIpc, [failedStep], { + await getCodeForFailedResult(mockApi, [failedStep], { status: 'failed', type: 'inline', steps: [ @@ -105,8 +103,8 @@ describe('shared', () => { ], }); - expect(mockIpc.callMain).toHaveBeenCalledTimes(1); - expect(mockIpc.callMain).toHaveBeenCalledWith('actions-to-code', { + expect(mockApi.generateCode).toHaveBeenCalledTimes(1); + expect(mockApi.generateCode).toHaveBeenCalledWith({ actions: [failedStep], isProject: false, }); diff --git a/src/common/shared.ts b/src/common/shared.ts index a34398dd..e12bfcf5 100644 --- a/src/common/shared.ts +++ b/src/common/shared.ts @@ -23,9 +23,8 @@ THE SOFTWARE. */ import type { Action /* , Steps */ } from '@elastic/synthetics'; -import { RendererProcessIpc } from 'electron-better-ipc'; import React from 'react'; -import type { ActionContext, Journey, JourneyType, Steps } from '../../common/types'; +import type { ActionContext, IElectronAPI, Journey, JourneyType, Steps } from '../../common/types'; export const COMMAND_SELECTOR_OPTIONS = [ { @@ -72,11 +71,11 @@ export const PLAYWRIGHT_ASSERTION_DOCS_LINK = 'https://playwright.dev/docs/asser export const SMALL_SCREEN_BREAKPOINT = 850; export async function getCodeFromActions( - ipc: RendererProcessIpc, + electronAPI: IElectronAPI, steps: Steps, type: JourneyType ): Promise { - return await ipc.callMain('actions-to-code', { + return electronAPI.generateCode({ actions: steps.map(({ actions, ...rest }) => ({ ...rest, actions: actions.filter(action => !(action as ActionContext)?.isSoftDeleted), @@ -86,12 +85,12 @@ export async function getCodeFromActions( } export function createExternalLinkHandler( - ipc: RendererProcessIpc, + electronAPI: IElectronAPI, url: string ): React.MouseEventHandler { return async e => { e.preventDefault(); - await ipc.callMain('link-to-external', url); + await electronAPI.openExternalLink(url); }; } @@ -102,7 +101,7 @@ export function createExternalLinkHandler( * @returns code string */ export async function getCodeForFailedResult( - ipc: RendererProcessIpc, + electronAPI: IElectronAPI, steps: Steps, journey?: Journey ): Promise { @@ -118,7 +117,7 @@ export async function getCodeForFailedResult( if (!failedStep) return ''; - return getCodeFromActions(ipc, [failedStep], journey.type); + return getCodeFromActions(electronAPI, [failedStep], journey.type); } export function actionTitle(action: Action) { diff --git a/src/components/Assertion/AssertionInfo.tsx b/src/components/Assertion/AssertionInfo.tsx index f40e0982..9c032b8c 100644 --- a/src/components/Assertion/AssertionInfo.tsx +++ b/src/components/Assertion/AssertionInfo.tsx @@ -52,7 +52,7 @@ const AssertionInfoText = styled(EuiText)` export function AssertionInfo() { const [isInfoPopoverOpen, setIsInfoPopoverOpen] = useState(false); - const { ipc } = useContext(CommunicationContext); + const { electronAPI } = useContext(CommunicationContext); return ( Read more diff --git a/src/components/ExportScriptFlyout/Flyout.tsx b/src/components/ExportScriptFlyout/Flyout.tsx index afadf6d9..dc3609f1 100644 --- a/src/components/ExportScriptFlyout/Flyout.tsx +++ b/src/components/ExportScriptFlyout/Flyout.tsx @@ -44,7 +44,7 @@ const LARGE_FLYOUT_SIZE_LINE_LENGTH = 100; export function ExportScriptFlyout({ setVisible, steps }: IExportScriptFlyout) { const [code, setCode] = useState(''); - const { ipc } = useContext(CommunicationContext); + const { electronAPI } = useContext(CommunicationContext); const [exportAsProject, setExportAsProject] = useState(false); const type: JourneyType = exportAsProject ? 'project' : 'inline'; @@ -57,10 +57,10 @@ export function ExportScriptFlyout({ setVisible, steps }: IExportScriptFlyout) { useEffect(() => { (async function getCode() { - const codeFromActions = await getCodeFromActions(ipc, steps, type); + const codeFromActions = await getCodeFromActions(electronAPI, steps, type); setCode(codeFromActions); })(); - }, [ipc, steps, setCode, type]); + }, [electronAPI, steps, setCode, type]); return ( Send feedback diff --git a/src/components/SaveCodeButton.test.tsx b/src/components/SaveCodeButton.test.tsx index a5cf1474..3834dce8 100644 --- a/src/components/SaveCodeButton.test.tsx +++ b/src/components/SaveCodeButton.test.tsx @@ -27,20 +27,17 @@ import { render } from '../helpers/test'; import { SaveCodeButton } from './SaveCodeButton'; import { createSteps } from '../../common/helper/test/createAction'; import { fireEvent, waitFor } from '@testing-library/react'; +import { getMockElectronApi } from '../helpers/test/ipc'; describe('SaveCodeButton', () => { it('calls ipc on click', async () => { - const callMain = jest.fn(); - callMain.mockImplementation(() => 'this would be generated code'); + const exportScript = jest.fn(); + exportScript.mockImplementation(() => 'this would be generated code'); const sendToast = jest.fn(); const { getByLabelText } = render(, { contextOverrides: { communication: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore partial implementation for test - ipc: { - callMain, - }, + electronAPI: getMockElectronApi({ exportScript }), }, steps: { steps: createSteps([['action1', 'action2', 'action3'], ['action4']]), @@ -56,7 +53,7 @@ describe('SaveCodeButton', () => { fireEvent.click(button); await waitFor(() => { - expect(callMain).toHaveBeenCalled(); + expect(exportScript).toHaveBeenCalled(); expect(sendToast).toHaveBeenCalledTimes(1); const mockExportObject = sendToast.mock.calls[0][0]; expect(mockExportObject.color).toBe('success'); diff --git a/src/components/SaveCodeButton.tsx b/src/components/SaveCodeButton.tsx index 9f8865ea..e9863a28 100644 --- a/src/components/SaveCodeButton.tsx +++ b/src/components/SaveCodeButton.tsx @@ -35,12 +35,12 @@ interface ISaveCodeButton { } export function SaveCodeButton({ type }: ISaveCodeButton) { - const { ipc } = useContext(CommunicationContext); + const { electronAPI } = useContext(CommunicationContext); const { steps } = useContext(StepsContext); const { sendToast } = useContext(ToastContext); const onSave = async () => { - const codeFromActions = await getCodeFromActions(ipc, steps, type); - const exported = await window.electronAPI.exportScript(codeFromActions); + const codeFromActions = await getCodeFromActions(electronAPI, steps, type); + const exported = await electronAPI.exportScript(codeFromActions); if (exported) { sendToast({ id: `file-export-${new Date().valueOf()}`, diff --git a/src/components/TestResult/TestResult.test.tsx b/src/components/TestResult/TestResult.test.tsx index e70e24d1..93bc07e3 100644 --- a/src/components/TestResult/TestResult.test.tsx +++ b/src/components/TestResult/TestResult.test.tsx @@ -26,6 +26,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import { createStep } from '../../../common/helper/test/createAction'; import { render } from '../../helpers/test'; +import { getMockElectronApi } from '../../helpers/test/ipc'; import { TestResult } from './TestResult'; describe('TestResult', () => { @@ -161,8 +162,8 @@ describe('TestResult', () => { it('renders the flyout with error message and code for failed step', async () => { const errorMessage = 'Save button timeout expired'; - const callMain = jest.fn(); - callMain.mockImplementation((arg: string, obj: any) => { + const generateCode = jest.fn(); + generateCode.mockImplementation((arg: string, obj: any) => { return ` step('Click save', () => { await page.click('my button'); @@ -170,7 +171,7 @@ step('Click save', () => { }); const { getByText, queryByText } = render(, { contextOverrides: { - communication: { ipc: { callMain } }, + communication: { electronAPI: getMockElectronApi({ generateCode }) }, steps: { steps: [createStep(['Click save'])] }, test: { isResultFlyoutVisible: true, diff --git a/src/components/TestResult/TestResult.tsx b/src/components/TestResult/TestResult.tsx index b62ea950..5b91c9b0 100644 --- a/src/components/TestResult/TestResult.tsx +++ b/src/components/TestResult/TestResult.tsx @@ -38,7 +38,7 @@ export function TestResult() { const { steps } = useContext(StepsContext); const { isResultFlyoutVisible, result, setResult, setIsResultFlyoutVisible } = useContext(TestContext); - const { ipc } = useContext(CommunicationContext); + const { electronAPI } = useContext(CommunicationContext); const [stepCodeToDisplay, setStepCodeToDisplay] = useState(''); /** @@ -54,7 +54,7 @@ export function TestResult() { return; } - const failedCode = await getCodeFromActions(ipc, [failedStep], 'inline'); + const failedCode = await getCodeFromActions(electronAPI, [failedStep], 'inline'); setStepCodeToDisplay(failedCode); } @@ -62,7 +62,7 @@ export function TestResult() { if (isResultFlyoutVisible && steps.length && result?.failed) { fetchCodeForFailure(result); } - }, [ipc, result, setResult, steps, isResultFlyoutVisible]); + }, [electronAPI, result, setResult, steps, isResultFlyoutVisible]); const maxLineLength = useMemo( () => stepCodeToDisplay.split('\n').reduce((prev, cur) => Math.max(prev, cur.length), 0), diff --git a/src/contexts/CommunicationContext.ts b/src/contexts/CommunicationContext.ts index af2a343b..6a908e0a 100644 --- a/src/contexts/CommunicationContext.ts +++ b/src/contexts/CommunicationContext.ts @@ -23,18 +23,12 @@ THE SOFTWARE. */ // @ts-nocheck import { createContext } from 'react'; - -// const { ipcRenderer } = window.require('electron-better-ipc'); +import { IElectronAPI } from '../../common/types'; export interface ICommunicationContext { - ipc: any; + electronAPI: IElectronAPI; } + export const CommunicationContext = createContext({ - ipc: { - callMain: () => {}, - send: () => {}, - on: () => {}, - removeListener(channel: any, listener: any) {}, - answerMain: () => {}, - }, + electronAPI: window.electronAPI, }); diff --git a/src/helpers/test/defaults.ts b/src/helpers/test/defaults.ts index c37b3436..49d00d05 100644 --- a/src/helpers/test/defaults.ts +++ b/src/helpers/test/defaults.ts @@ -82,10 +82,17 @@ export const getToastContextDefaults = (): IToastContext => ({ }); export const getCommunicationContextDefaults = (): ICommunicationContext => ({ - ipc: { - answerMain: jest.fn(), - callMain: jest.fn(), - removeListener: jest.fn(), + electronAPI: { + exportScript: jest.fn(), + recordJourney: jest.fn(), + stopRecording: jest.fn(), + pauseRecording: jest.fn(), + resumeRecording: jest.fn(), + onActionGenerated: jest.fn(), + generateCode: jest.fn(), + openExternalLink: jest.fn(), + runTest: jest.fn(), + removeOnTestListener: jest.fn(), }, }); diff --git a/src/helpers/test/ipc.ts b/src/helpers/test/ipc.ts index 6dbb39c1..42f5f9fb 100644 --- a/src/helpers/test/ipc.ts +++ b/src/helpers/test/ipc.ts @@ -22,33 +22,20 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { RendererProcessIpc } from 'electron-better-ipc'; +import { IElectronAPI } from '../../../common/types'; -export function getMockIpc(overrides?: Partial): RendererProcessIpc { +export function getMockElectronApi(overrides?: Partial): IElectronAPI { return { - addListener: jest.fn(), - answerMain: jest.fn(), - callMain: jest.fn(), - emit: jest.fn(), - eventNames: jest.fn(), - getMaxListeners: jest.fn(), - invoke: jest.fn(), - listenerCount: jest.fn(), - listeners: jest.fn(), - off: jest.fn(), - on: jest.fn(), - once: jest.fn(), - postMessage: jest.fn(), - prependListener: jest.fn(), - prependOnceListener: jest.fn(), - rawListeners: jest.fn(), - removeAllListeners: jest.fn(), - removeListener: jest.fn(), - send: jest.fn(), - sendSync: jest.fn(), - sendTo: jest.fn(), - sendToHost: jest.fn(), - setMaxListeners: jest.fn(), + exportScript: jest.fn(), + recordJourney: jest.fn(), + stopRecording: jest.fn(), + pauseRecording: jest.fn(), + resumeRecording: jest.fn(), + onActionGenerated: jest.fn(), + generateCode: jest.fn(), + openExternalLink: jest.fn(), + runTest: jest.fn(), + removeOnTestListener: jest.fn(), ...overrides, }; } diff --git a/src/hooks/useRecordingContext.test.ts b/src/hooks/useRecordingContext.test.ts index d9fe7af2..2f974806 100644 --- a/src/hooks/useRecordingContext.test.ts +++ b/src/hooks/useRecordingContext.test.ts @@ -37,32 +37,30 @@ THE SOFTWARE. */ import { act, renderHook } from '@testing-library/react-hooks'; -import { RendererProcessIpc } from 'electron-better-ipc'; -import { RecorderSteps } from '../../common/types'; +import { IElectronAPI, RecorderSteps } from '../../common/types'; import { RecordingStatus, Setter } from '../common/types'; -import { getMockIpc } from '../helpers/test/ipc'; +import { getMockElectronApi } from '../helpers/test/ipc'; import { useRecordingContext } from './useRecordingContext'; describe('useRecordingContext', () => { - let ipc: RendererProcessIpc; + let electronApi: IElectronAPI; let setResult: (data: undefined) => void; let setSteps: Setter; - let callMainMock: jest.Mock; - let sendMock: jest.Mock; + let recordJourney: jest.Mock; beforeEach(() => { - callMainMock = jest.fn(); - sendMock = jest.fn(); - ipc = getMockIpc({ - callMain: callMainMock, - send: sendMock, + recordJourney = jest.fn(); + electronApi = getMockElectronApi({ + recordJourney, }); setResult = jest.fn(); setSteps = jest.fn(); }); it('should initialize the recording context with the correct initial state', () => { - const { result } = renderHook(() => useRecordingContext(ipc, '', 0, setResult, setSteps)); + const { result } = renderHook(() => + useRecordingContext(electronApi, '', 0, setResult, setSteps) + ); const recordingContext = result.current; expect(recordingContext.recordingStatus).toEqual(RecordingStatus.NotRecording); @@ -71,7 +69,7 @@ describe('useRecordingContext', () => { it('should toggle the recording status when toggleRecording is called', async () => { const renderHookResponse = renderHook(() => - useRecordingContext(ipc, '', 0, setResult, setSteps) + useRecordingContext(electronApi, '', 0, setResult, setSteps) ); const recordingContext = renderHookResponse.result.current; @@ -79,12 +77,12 @@ describe('useRecordingContext', () => { renderHookResponse.rerender(); - expect(callMainMock).toHaveBeenCalledWith('record-journey', { url: '' }); + expect(recordJourney).toHaveBeenCalledWith(''); }); it('sets start over modal visible if a step exists', async () => { const renderHookResponse = renderHook(() => - useRecordingContext(ipc, '', 1, setResult, setSteps) + useRecordingContext(electronApi, '', 1, setResult, setSteps) ); const recordingContext = renderHookResponse.result.current; @@ -97,7 +95,7 @@ describe('useRecordingContext', () => { it('startOver resets steps and runs a new recording session', async () => { const renderHookResponse = renderHook(() => - useRecordingContext(ipc, 'https://test.com', 1, setResult, setSteps) + useRecordingContext(electronApi, 'https://test.com', 1, setResult, setSteps) ); const recordingContext = renderHookResponse.result.current; @@ -105,13 +103,13 @@ describe('useRecordingContext', () => { renderHookResponse.rerender(); - expect(callMainMock).toHaveBeenCalledWith('record-journey', { url: 'https://test.com' }); + expect(recordJourney).toHaveBeenCalledWith('https://test.com'); expect(setSteps).toHaveBeenCalledWith([]); }); it('togglePause does nothing when not recording', async () => { const renderHookResponse = renderHook(() => - useRecordingContext(ipc, 'https://test.com', 1, setResult, setSteps) + useRecordingContext(electronApi, 'https://test.com', 1, setResult, setSteps) ); const recordingContext = renderHookResponse.result.current; @@ -119,6 +117,6 @@ describe('useRecordingContext', () => { renderHookResponse.rerender(); - expect(callMainMock).not.toHaveBeenCalled(); + expect(recordJourney).not.toHaveBeenCalled(); }); }); diff --git a/src/hooks/useRecordingContext.ts b/src/hooks/useRecordingContext.ts index 729f80ff..f45ed43d 100644 --- a/src/hooks/useRecordingContext.ts +++ b/src/hooks/useRecordingContext.ts @@ -24,8 +24,7 @@ THE SOFTWARE. import { useCallback, useState } from 'react'; import { RecordingStatus, Setter } from '../common/types'; -import { RecorderSteps } from '../../common/types'; -import { RendererProcessIpc } from 'electron-better-ipc'; +import { IElectronAPI, RecorderSteps } from '../../common/types'; import { IRecordingContext } from '../contexts/RecordingContext'; /** @@ -37,7 +36,7 @@ import { IRecordingContext } from '../contexts/RecordingContext'; * @returns state/functions to manage recording. */ export function useRecordingContext( - ipc: RendererProcessIpc, + electronAPI: IElectronAPI, url: string, stepCount: number, setResult: (data: undefined) => void, @@ -52,14 +51,14 @@ export function useRecordingContext( setRecordingStatus(RecordingStatus.NotRecording); // Stop browser process // ipc.send('stop'); - window.electronAPI.stopRecording(); + electronAPI.stopRecording(); } else { setRecordingStatus(RecordingStatus.Recording); // await ipc.callMain('record-journey', { url }); - await window.electronAPI.recordJourney(url); + await electronAPI.recordJourney(url); setRecordingStatus(RecordingStatus.NotRecording); } - }, [ipc, recordingStatus, stepCount, url]); + }, [electronAPI, recordingStatus, stepCount, url]); const startOver = useCallback(async () => { setSteps([]); @@ -69,20 +68,20 @@ export function useRecordingContext( // a previous journey we need to discard its result status setResult(undefined); // await ipc.callMain('record-journey', { url }); - await window.electronAPI.recordJourney(url); + await electronAPI.recordJourney(url); setRecordingStatus(RecordingStatus.NotRecording); } - }, [ipc, recordingStatus, setResult, setSteps, url]); + }, [electronAPI, recordingStatus, setResult, setSteps, url]); const togglePause = async () => { if (recordingStatus === RecordingStatus.NotRecording) return; if (recordingStatus !== RecordingStatus.Paused) { setRecordingStatus(RecordingStatus.Paused); // await ipc.callMain('set-mode', 'none'); - await window.electronAPI.pauseRecording(); + await electronAPI.pauseRecording(); } else { // await ipc.callMain('set-mode', 'recording'); - await window.electronAPI.resumeRecording(); + await electronAPI.resumeRecording(); setRecordingStatus(RecordingStatus.Recording); } }; diff --git a/src/hooks/useSyntheticsTest.test.tsx b/src/hooks/useSyntheticsTest.test.tsx index a3777acf..46c12eea 100644 --- a/src/hooks/useSyntheticsTest.test.tsx +++ b/src/hooks/useSyntheticsTest.test.tsx @@ -35,23 +35,25 @@ THE SOFTWARE. import React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; -import { RendererProcessIpc } from 'electron-better-ipc'; -import { getMockIpc } from '../helpers/test/ipc'; +import { getMockElectronApi } from '../helpers/test/ipc'; import { TestContextWrapper } from '../helpers/test/render'; import { useSyntheticsTest } from './useSyntheticsTest'; -import { RecorderSteps, Result } from '../../common/types'; +import { IElectronAPI, RecorderSteps, Result } from '../../common/types'; import { createSteps } from '../../common/helper/test/createAction'; import * as communicationHelpers from '../common/shared'; describe('useSyntheticsTest', () => { - let ipc: RendererProcessIpc; + let electronAPI: IElectronAPI; beforeEach(() => { - ipc = getMockIpc(); + electronAPI = getMockElectronApi(); }); const wrapper = ({ children }: { children?: React.ReactNode }) => ( - + ); it('should initiailize the test context with the correct state', () => { diff --git a/src/hooks/useSyntheticsTest.ts b/src/hooks/useSyntheticsTest.ts index f5137a55..f7fe13ee 100644 --- a/src/hooks/useSyntheticsTest.ts +++ b/src/hooks/useSyntheticsTest.ts @@ -35,7 +35,7 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { const [isResultFlyoutVisible, setIsResultFlyoutVisible] = useState(false); const [codeBlocks, setCodeBlocks] = useState(''); const [isTestInProgress, setIsTestInProgress] = useState(false); - const { ipc } = useContext(CommunicationContext); + const { electronAPI } = useContext(CommunicationContext); const setResult = useCallback((data: Result | undefined) => { dispatch({ @@ -56,7 +56,7 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { const onTest = useCallback( async function () { - const code = await getCodeFromActions(ipc, steps, 'inline'); + const code = await getCodeFromActions(electronAPI, steps, 'inline'); if (!isTestInProgress) { // destroy stale state dispatch({ data: undefined, event: 'override' }); @@ -64,14 +64,17 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { dispatch(data); }; - ipc.on('test-event', onTestEvent); + // electronAPI.on('test-event', onTestEvent); try { - const promise = ipc.callMain('run-journey', { - steps, - code, - isProject: false, - }); + const promise = electronAPI.runTest( + { + steps, + code, + isProject: false, + }, + onTestEvent + ); setIsTestInProgress(true); setIsResultFlyoutVisible(true); await promise; @@ -79,19 +82,19 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { // eslint-disable-next-line no-console console.error(e); } finally { - ipc.removeListener('test-event', onTestEvent); + electronAPI.removeOnTestListener(); setIsTestInProgress(false); } } }, - [ipc, isTestInProgress, steps] + [electronAPI, isTestInProgress, steps] ); useEffect(() => { if (result?.journey.status === 'failed') { - getCodeForFailedResult(ipc, steps, result?.journey).then(code => setCodeBlocks(code)); + getCodeForFailedResult(electronAPI, steps, result?.journey).then(code => setCodeBlocks(code)); } - }, [ipc, result?.journey, steps]); + }, [electronAPI, result?.journey, steps]); return { codeBlocks, From d95a18d72f0876979e15f1bcbc2d8f373df08bea Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Tue, 7 Feb 2023 02:44:27 +0900 Subject: [PATCH 05/14] chore: remove unused dep --- NOTICE.txt | 15 --------------- jest.unit.config.js | 1 - jest.unit.setup.js | 38 -------------------------------------- package-lock.json | 20 -------------------- package.json | 1 - 5 files changed, 75 deletions(-) delete mode 100644 jest.unit.setup.js diff --git a/NOTICE.txt b/NOTICE.txt index 04ed6fba..c30d3580 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -212,21 +212,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ---- -This product relies on electron-better-ipc - -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - - --- This product relies on electron-debug diff --git a/jest.unit.config.js b/jest.unit.config.js index f46ff405..85ec9f04 100644 --- a/jest.unit.config.js +++ b/jest.unit.config.js @@ -38,7 +38,6 @@ module.exports = { coveragePathIgnorePatterns: ['.test.ts', '.test.js', '.test.tsx', '.test.jsx'], testEnvironment: 'jsdom', testMatch: ['**/?(*.)+(spec|test).[tj]sx'], - setupFilesAfterEnv: ['./jest.unit.setup.js'], testPathIgnorePatterns: [`node_modules`, `\\.cache`, `e2e`, `build`], resolver: `./tests/common/resolver.js`, }, diff --git a/jest.unit.setup.js b/jest.unit.setup.js deleted file mode 100644 index a131060a..00000000 --- a/jest.unit.setup.js +++ /dev/null @@ -1,38 +0,0 @@ -/* -MIT License - -Copyright (c) 2021-present, Elastic NV - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - -/** - * We do this because there are numerous places in the frontend code that - * need to call `window.require("electron-better-ipc"). Because the tests - * are not configured to run with node env features, `window.require` will - * not be defined. - * - * We rely on e2e testing to ensure that our communications via IPC to the - * electron process are working, so for the most part we can ignore this - * feature, and mock its responses when testing frontend functionality in - * unit tests. This setup will preclude the need to include the declaration - * of `require` on the `window` object before any test that depends on this. - */ - -window.require = require; diff --git a/package-lock.json b/package-lock.json index 6f85b868..ac606bad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@emotion/cache": "^11.9.3", "@emotion/react": "^11.9.3", "dotenv": "^16.0.3", - "electron-better-ipc": "^2.0.1", "electron-debug": "^3.2.0", "electron-is-dev": "^2.0.0", "electron-log": "^4.4.8", @@ -9176,17 +9175,6 @@ "node": ">= 10.17.0" } }, - "node_modules/electron-better-ipc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/electron-better-ipc/-/electron-better-ipc-2.0.1.tgz", - "integrity": "sha512-S/h2vjQjev9FVicGnZeuP2wH/ycO9pOv63JGaTjYB2m5JcKDuXHAM713YmnmDC+VVXP7mh1Y9rtxi6BoNK7YJA==", - "dependencies": { - "serialize-error": "^8.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/electron-builder": { "version": "23.6.0", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-23.6.0.tgz", @@ -33473,14 +33461,6 @@ } } }, - "electron-better-ipc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/electron-better-ipc/-/electron-better-ipc-2.0.1.tgz", - "integrity": "sha512-S/h2vjQjev9FVicGnZeuP2wH/ycO9pOv63JGaTjYB2m5JcKDuXHAM713YmnmDC+VVXP7mh1Y9rtxi6BoNK7YJA==", - "requires": { - "serialize-error": "^8.1.0" - } - }, "electron-builder": { "version": "23.6.0", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-23.6.0.tgz", diff --git a/package.json b/package.json index cb0954bf..d1a8225e 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "@emotion/cache": "^11.9.3", "@emotion/react": "^11.9.3", "dotenv": "^16.0.3", - "electron-better-ipc": "^2.0.1", "electron-debug": "^3.2.0", "electron-is-dev": "^2.0.0", "electron-log": "^4.4.8", From 200b8a610319f1298549a274d42433e2f0533778 Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Tue, 7 Feb 2023 02:48:55 +0900 Subject: [PATCH 06/14] chore: revert temp eslint config --- .eslintrc.js | 3 --- src/contexts/CommunicationContext.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 224d4fd6..c8229e37 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,9 +50,6 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-non-null-assertion': 0, '@typescript-eslint/no-explicit-any': 0, - // temporary mute - '@typescript-eslint/ban-ts-comment': 0, - '@typescript-eslint/no-empty-function': 0, }, }, ], diff --git a/src/contexts/CommunicationContext.ts b/src/contexts/CommunicationContext.ts index 6a908e0a..48427e29 100644 --- a/src/contexts/CommunicationContext.ts +++ b/src/contexts/CommunicationContext.ts @@ -21,7 +21,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// @ts-nocheck import { createContext } from 'react'; import { IElectronAPI } from '../../common/types'; From 984f0ab24d84719402b2c171733306c3dea2d72c Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Tue, 7 Feb 2023 16:53:42 +0900 Subject: [PATCH 07/14] chore: tidy up onRunJourney --- electron/api/generateCode.ts | 34 ++++ electron/api/index.ts | 3 + electron/api/openExternalLink.ts | 33 ++++ electron/api/recordJourney.ts | 72 ++++---- electron/api/runJourney.ts | 214 ++++++++++++++++++++++++ electron/api/setMode.ts | 4 - electron/execution.ts | 275 ++++--------------------------- electron/preload.ts | 2 +- electron/syntheticsManager.ts | 53 ++++++ 9 files changed, 408 insertions(+), 282 deletions(-) create mode 100644 electron/api/generateCode.ts create mode 100644 electron/api/openExternalLink.ts create mode 100644 electron/api/runJourney.ts create mode 100644 electron/syntheticsManager.ts diff --git a/electron/api/generateCode.ts b/electron/api/generateCode.ts new file mode 100644 index 00000000..5aebdbb3 --- /dev/null +++ b/electron/api/generateCode.ts @@ -0,0 +1,34 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import { IpcMainInvokeEvent } from 'electron'; +import { RecorderSteps } from '../../common/types'; +import { SyntheticsGenerator } from '../syntheticsGenerator'; + +export async function onGenerateCode( + _event: IpcMainInvokeEvent, + data: { isProject: boolean; actions: RecorderSteps } +) { + const generator = new SyntheticsGenerator(data.isProject); + return generator.generateFromSteps(data.actions); +} diff --git a/electron/api/index.ts b/electron/api/index.ts index 8706a273..df40a346 100644 --- a/electron/api/index.ts +++ b/electron/api/index.ts @@ -24,3 +24,6 @@ THE SOFTWARE. export * from './recordJourney'; export * from './setMode'; export * from './exportScript'; +export * from './runJourney'; +export * from './openExternalLink'; +export * from './generateCode'; diff --git a/electron/api/openExternalLink.ts b/electron/api/openExternalLink.ts new file mode 100644 index 00000000..0d3de3f4 --- /dev/null +++ b/electron/api/openExternalLink.ts @@ -0,0 +1,33 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import { IpcMainInvokeEvent, shell } from 'electron'; +import logger from 'electron-log'; + +export async function onOpenExternalLink(_event: IpcMainInvokeEvent, url: string) { + try { + await shell.openExternal(url); + } catch (e) { + logger.error(e); + } +} diff --git a/electron/api/recordJourney.ts b/electron/api/recordJourney.ts index e183079e..9691078c 100644 --- a/electron/api/recordJourney.ts +++ b/electron/api/recordJourney.ts @@ -31,47 +31,43 @@ import logger from 'electron-log'; import { ActionInContext } from '../../common/types'; import { BrowserManager } from '../browserManager'; -export function onRecordJourneys(browserManager: BrowserManager) { - return async function (_event: IpcMainInvokeEvent, url: string) { - const browserWindow = BrowserWindow.getFocusedWindow()!; - if (browserManager.isRunning()) { - throw new Error( - 'Cannot start recording a journey, a browser operation is already in progress.' - ); - } +export async function recordJourney( + _event: IpcMainInvokeEvent, + url: string, + browserManager: BrowserManager +) { + const browserWindow = BrowserWindow.getFocusedWindow()!; + try { + const { browser, context } = await browserManager.launchBrowser(); + const actionListener = new EventEmitter(); - try { - const { browser, context } = await browserManager.launchBrowser(); - const actionListener = new EventEmitter(); + ipcMain.handleOnce('stop-recording', async () => { + actionListener.removeListener('actions', actionsHandler); + await browserManager.closeBrowser(); + }); - ipcMain.handleOnce('stop-recording', async () => { - actionListener.removeListener('actions', actionsHandler); - await browserManager.closeBrowser(); - }); + // Listen to actions from Playwright recording session + const actionsHandler = (actions: ActionInContext[]) => { + // ipcMain.callRenderer(browserWindow, 'change', { actions }); + browserWindow.webContents.send('change', actions); + }; + actionListener.on('actions', actionsHandler); - // Listen to actions from Playwright recording session - const actionsHandler = (actions: ActionInContext[]) => { - // ipcMain.callRenderer(browserWindow, 'change', { actions }); - browserWindow.webContents.send('change', actions); - }; - actionListener.on('actions', actionsHandler); - - // _enableRecorder is private method, not defined in BrowserContext type - await (context as any)._enableRecorder({ - launchOptions: {}, - contextOptions: {}, - mode: 'recording', - showRecorder: false, - actionListener, - }); - await openPage(context, url); - await once(browser, 'disconnected'); - } catch (e) { - logger.error(e); - } finally { - ipcMain.removeHandler('stop-recording'); - } - }; + // _enableRecorder is private method, not defined in BrowserContext type + await (context as any)._enableRecorder({ + launchOptions: {}, + contextOptions: {}, + mode: 'recording', + showRecorder: false, + actionListener, + }); + await openPage(context, url); + await once(browser, 'disconnected'); + } catch (e) { + logger.error(e); + } finally { + ipcMain.removeHandler('stop-recording'); + } } async function openPage(context: BrowserContext, url: string) { diff --git a/electron/api/runJourney.ts b/electron/api/runJourney.ts new file mode 100644 index 00000000..e90c9844 --- /dev/null +++ b/electron/api/runJourney.ts @@ -0,0 +1,214 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import path from 'path'; +import { writeFile, rm, mkdir } from 'fs/promises'; +import { BrowserWindow, IpcMainInvokeEvent } from 'electron'; +import logger from 'electron-log'; +import isDev from 'electron-is-dev'; +import type { + ActionInContext, + RecorderSteps, + RunJourneyOptions, + StepEndEvent, + StepStatus, + TestEvent, +} from '../../common/types'; + +import { JOURNEY_DIR, PLAYWRIGHT_BROWSERS_PATH } from '../config'; +import { SyntheticsManager } from '../syntheticsManager'; + +export async function runJourney( + _event: IpcMainInvokeEvent, + data: RunJourneyOptions, + syntheticsManager: SyntheticsManager +) { + const constructEvent = (parsed: Record): TestEvent | null => { + const isJourneyStart = (event: any): event is { journey: { name: string } } => { + return event.type === 'journey/start' && !!event.journey.name; + }; + + const isStepEnd = ( + event: any + ): event is { + step: { duration: { us: number }; name: string; status: StepStatus }; + error?: Error; + } => { + return ( + event.type === 'step/end' && + ['succeeded', 'failed', 'skipped'].includes(event.step?.status) && + typeof event.step?.duration?.us === 'number' + ); + }; + + const isJourneyEnd = ( + event: any + ): event is { journey: { name: string; status: 'succeeded' | 'failed' } } => { + return ( + event.type === 'journey/end' && ['succeeded', 'failed'].includes(event.journey?.status) + ); + }; + + if (isJourneyStart(parsed)) { + return { + event: 'journey/start', + data: { + name: parsed.journey.name, + }, + }; + } + + if (isStepEnd(parsed)) { + return { + event: 'step/end', + data: { + name: parsed.step.name, + status: parsed.step.status, + duration: Math.ceil(parsed.step.duration.us / 1000), + error: parsed.error, + }, + }; + } + if (isJourneyEnd(parsed)) { + return { + event: 'journey/end', + data: { + name: parsed.journey.name, + status: parsed.journey.status, + }, + }; + } + return null; + }; + + // TODO: de-deup browserWindow getter + const browserWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; + const sendTestEvent = (event: TestEvent) => { + browserWindow.webContents.send('test-event', event); + }; + + const emitResult = (chunk: string) => { + const parseOrSkip = (chunk: string): Array> => { + // at times stdout ships multiple steps in one chunk, broken by newline, + // so here we split on the newline + return chunk.split('\n').map(subChunk => { + try { + return JSON.parse(subChunk); + } catch (_) { + return {}; + } + }); + }; + parseOrSkip(chunk).forEach(parsed => { + const event = constructEvent(parsed); + if (event) { + sendTestEvent( + event.event === 'step/end' ? addActionsToStepResult(data.steps, event) : event + ); + } + }); + }; + + try { + const isProject = data.isProject; + const args = [ + '--no-headless', + '--reporter=json', + '--screenshots=off', + '--no-throttling', + '--sandbox', + ]; + const filePath = path.join(JOURNEY_DIR, 'recorded.journey.js'); + if (!isProject) { + args.push('--inline'); + } else { + await mkdir(JOURNEY_DIR, { recursive: true }); + await writeFile(filePath, data.code); + args.unshift(filePath); + } + + const { stdout, stdin, stderr } = syntheticsManager.run(args, { + env: { + ...process.env, + PLAYWRIGHT_BROWSERS_PATH, + }, + cwd: isDev ? process.cwd() : process.resourcesPath, + stdio: 'pipe', + }); + + if (!isProject) { + stdin?.write(data.code); + stdin?.end(); + } + stdout?.setEncoding('utf-8'); + stderr?.setEncoding('utf-8'); + for await (const chunk of stdout!) { + emitResult(chunk); + } + for await (const chunk of stderr!) { + logger.error(chunk); + } + if (isProject) { + await rm(filePath, { recursive: true, force: true }); + } + } catch (error: unknown) { + logger.error(error); + sendTestEvent({ + event: 'journey/end', + data: { + status: 'failed', + error: error as Error, + }, + }); + } finally { + await syntheticsManager.stop(); + } +} + +/** + * Attempts to find the step associated with a `step/end` event. + * + * If the step is found, the sequential titles of each action are overlayed + * onto the object. + * @param {*} steps list of steps to search + * @param {*} event the result data from Playwright + * @returns the event data combined with action titles in a new object + */ +function addActionsToStepResult(steps: RecorderSteps, event: StepEndEvent): TestEvent { + const step = steps.find( + s => + s.actions.length && + s.actions[0].title && + (event.data.name === s.actions[0].title || event.data.name === s.name) + ); + if (!step) return { ...event, data: { ...event.data, actionTitles: [] } }; + return { + ...event, + data: { + ...event.data, + actionTitles: step.actions.map( + (action: ActionInContext, index: number) => action?.title ?? `Action ${index + 1}` + ), + }, + }; +} diff --git a/electron/api/setMode.ts b/electron/api/setMode.ts index 77c2a09a..0ea12879 100644 --- a/electron/api/setMode.ts +++ b/electron/api/setMode.ts @@ -37,9 +37,5 @@ export function onSetMode(browserManager: BrowserManager) { }, [mode] ); - if (mode !== 'inspecting') return; - // TODO: see if deleting code below doesn't have any affects - // const [selector] = await once(actionListener, 'selector'); - // return selector; }; } diff --git a/electron/execution.ts b/electron/execution.ts index de9e1dd6..e3d4b43f 100644 --- a/electron/execution.ts +++ b/electron/execution.ts @@ -22,248 +22,24 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { join } from 'path'; -import { writeFile, rm, mkdir } from 'fs/promises'; import { EventEmitter } from 'events'; -import { shell, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; -import { fork, ChildProcess } from 'child_process'; -import logger from 'electron-log'; -import isDev from 'electron-is-dev'; -import { JOURNEY_DIR, PLAYWRIGHT_BROWSERS_PATH } from './config'; -import type { - ActionInContext, - RecorderSteps, - RunJourneyOptions, - StepEndEvent, - StepStatus, - TestEvent, -} from '../common/types'; -import { SyntheticsGenerator } from './syntheticsGenerator'; -import { BrowserManager, browserManager } from './browserManager'; -import { onRecordJourneys, onSetMode, onExportScript } from './api'; +import { ipcMain, IpcMainInvokeEvent } from 'electron'; +import type { RunJourneyOptions } from '../common/types'; +import { browserManager } from './browserManager'; +import { + onSetMode, + onExportScript, + runJourney, + recordJourney, + onOpenExternalLink, + onGenerateCode, +} from './api'; +import { syntheticsManager } from './syntheticsManager'; -const SYNTHETICS_CLI = require.resolve('@elastic/synthetics/dist/cli'); - -// TODO: setting isBrowserRunning from onRecordJourney is broken export enum MainWindowEvent { MAIN_CLOSE = 'main-close', } -/** - * Attempts to find the step associated with a `step/end` event. - * - * If the step is found, the sequential titles of each action are overlayed - * onto the object. - * @param {*} steps list of steps to search - * @param {*} event the result data from Playwright - * @returns the event data combined with action titles in a new object - */ -function addActionsToStepResult(steps: RecorderSteps, event: StepEndEvent): TestEvent { - const step = steps.find( - s => - s.actions.length && - s.actions[0].title && - (event.data.name === s.actions[0].title || event.data.name === s.name) - ); - if (!step) return { ...event, data: { ...event.data, actionTitles: [] } }; - return { - ...event, - data: { - ...event.data, - actionTitles: step.actions.map( - (action: ActionInContext, index: number) => action?.title ?? `Action ${index + 1}` - ), - }, - }; -} - -function onTest(browserManager: BrowserManager) { - return async function (_event: IpcMainInvokeEvent, data: RunJourneyOptions) { - if (browserManager.isRunning()) { - throw new Error( - 'Cannot start testing a journey, a browser operation is already in progress.' - ); - } - // TODO: connect onTest with browserManager and refactor - // browserManager.isRunning() = true; - const parseOrSkip = (chunk: string): Array> => { - // at times stdout ships multiple steps in one chunk, broken by newline, - // so here we split on the newline - return chunk.split('\n').map(subChunk => { - try { - return JSON.parse(subChunk); - } catch (_) { - return {}; - } - }); - }; - const isJourneyStart = (event: any): event is { journey: { name: string } } => { - return event.type === 'journey/start' && !!event.journey.name; - }; - - const isStepEnd = ( - event: any - ): event is { - step: { duration: { us: number }; name: string; status: StepStatus }; - error?: Error; - } => { - return ( - event.type === 'step/end' && - ['succeeded', 'failed', 'skipped'].includes(event.step?.status) && - typeof event.step?.duration?.us === 'number' - ); - }; - - const isJourneyEnd = ( - event: any - ): event is { journey: { name: string; status: 'succeeded' | 'failed' } } => { - return ( - event.type === 'journey/end' && ['succeeded', 'failed'].includes(event.journey?.status) - ); - }; - - const constructEvent = (parsed: Record): TestEvent | null => { - if (isJourneyStart(parsed)) { - return { - event: 'journey/start', - data: { - name: parsed.journey.name, - }, - }; - } - if (isStepEnd(parsed)) { - return { - event: 'step/end', - data: { - name: parsed.step.name, - status: parsed.step.status, - duration: Math.ceil(parsed.step.duration.us / 1000), - error: parsed.error, - }, - }; - } - if (isJourneyEnd(parsed)) { - return { - event: 'journey/end', - data: { - name: parsed.journey.name, - status: parsed.journey.status, - }, - }; - } - return null; - }; - // TODO: de-deup browserWindow getter - const browserWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; - const sendTestEvent = (event: TestEvent) => { - browserWindow.webContents.send('test-event', event); - }; - - const emitResult = (chunk: string) => { - parseOrSkip(chunk).forEach(parsed => { - const event = constructEvent(parsed); - if (event) { - sendTestEvent( - event.event === 'step/end' ? addActionsToStepResult(data.steps, event) : event - ); - } - }); - }; - - let synthCliProcess: ChildProcess | null = null; // child process, define here to kill when finished - - try { - const isProject = data.isProject; - const args = [ - '--no-headless', - '--reporter=json', - '--screenshots=off', - '--no-throttling', - '--sandbox', - ]; - const filePath = join(JOURNEY_DIR, 'recorded.journey.js'); - if (!isProject) { - args.push('--inline'); - } else { - await mkdir(JOURNEY_DIR, { recursive: true }); - await writeFile(filePath, data.code); - args.unshift(filePath); - } - - /** - * Fork the Synthetics CLI with correct browser path and - * cwd correctly spawns the process - */ - synthCliProcess = fork(`${SYNTHETICS_CLI}`, args, { - env: { - ...process.env, - PLAYWRIGHT_BROWSERS_PATH, - }, - cwd: isDev ? process.cwd() : process.resourcesPath, - stdio: 'pipe', - }); - - function handleMainClose() { - if (synthCliProcess && !synthCliProcess.kill()) { - logger.warn('Unable to abort Synthetics test proceess.'); - } - } - // mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); - - const { stdout, stdin, stderr } = synthCliProcess as ChildProcess; - if (!isProject) { - stdin?.write(data.code); - stdin?.end(); - } - stdout?.setEncoding('utf-8'); - stderr?.setEncoding('utf-8'); - for await (const chunk of stdout!) { - emitResult(chunk); - } - for await (const chunk of stderr!) { - logger.error(chunk); - } - if (isProject) { - await rm(filePath, { recursive: true, force: true }); - } - - // mainWindowEmitter.removeListener(MainWindowEvent.MAIN_CLOSE, handleMainClose); - } catch (error: unknown) { - logger.error(error); - sendTestEvent({ - event: 'journey/end', - data: { - status: 'failed', - error: error as Error, - }, - }); - } finally { - if (synthCliProcess && !synthCliProcess.kill()) { - logger.warn( - `Attempted to send SIGTERM to synthetics process, but did not receive exit signal. Process ID is ${synthCliProcess.pid}.` - ); - } - // isBrowserRunning = false; - } - }; -} - -async function onGenerateCode( - _event: IpcMainInvokeEvent, - data: { isProject: boolean; actions: RecorderSteps } -) { - const generator = new SyntheticsGenerator(data.isProject); - return generator.generateFromSteps(data.actions); -} - -async function onLinkExternal(_event: IpcMainInvokeEvent, url: string) { - try { - await shell.openExternal(url); - } catch (e) { - logger.error(e); - } -} - /** * Sets up IPC listeners for the main process to respond to UI events. * @@ -278,13 +54,34 @@ export default function setupListeners(mainWindowEmitter: EventEmitter) { if (browserManager.isRunning()) { await browserManager.closeBrowser(); } + + if (syntheticsManager.isRunning()) { + await syntheticsManager.stop(); + } }); - ipcMain.handle('record-journey', onRecordJourneys(browserManager)); - ipcMain.handle('run-journey', onTest(browserManager)); + + ipcMain.handle('record-journey', onRecordJourney); + ipcMain.handle('run-journey', onRunJourney); ipcMain.handle('actions-to-code', onGenerateCode); ipcMain.handle('export-script', onExportScript); ipcMain.handle('set-mode', onSetMode(browserManager)); - ipcMain.handle('link-to-external', onLinkExternal); + ipcMain.handle('open-external-link', onOpenExternalLink); return () => ipcMain.removeAllListeners(); } + +async function onRecordJourney(event: IpcMainInvokeEvent, url: string) { + if (browserManager.isRunning() || syntheticsManager.isRunning()) { + throw new Error( + 'Cannot start recording a journey, a browser operation is already in progress.' + ); + } + await recordJourney(event, url, browserManager); +} + +async function onRunJourney(event: IpcMainInvokeEvent, data: RunJourneyOptions) { + if (browserManager.isRunning() || syntheticsManager.isRunning()) { + throw new Error('Cannot start testing a journey, a browser operation is already in progress.'); + } + await runJourney(event, data, syntheticsManager); +} diff --git a/electron/preload.ts b/electron/preload.ts index 02d02bcc..298a1ca0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -58,7 +58,7 @@ const electronAPI: IElectronAPI = { return ipcRenderer.invoke('actions-to-code', params); }, openExternalLink: async (url: string) => { - await ipcRenderer.invoke('link-to-external', url); + await ipcRenderer.invoke('open-external-link', url); }, runTest: async ( params: RunJourneyOptions, diff --git a/electron/syntheticsManager.ts b/electron/syntheticsManager.ts new file mode 100644 index 00000000..44c3aee1 --- /dev/null +++ b/electron/syntheticsManager.ts @@ -0,0 +1,53 @@ +/* +MIT License + +Copyright (c) 2021-present, Elastic NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +import { fork, ChildProcess, ForkOptions } from 'child_process'; +import logger from 'electron-log'; +const SYNTHETICS_CLI = require.resolve('@elastic/synthetics/dist/cli'); + +export class SyntheticsManager { + protected _cliProcess: ChildProcess | null = null; + + isRunning() { + return !!this._cliProcess; + } + + /** + * Fork the Synthetics CLI with correct browser path and + * cwd correctly spawns the process + */ + run(args: string[], options: ForkOptions) { + const ps = fork(`${SYNTHETICS_CLI}`, args, options); + this._cliProcess = ps; + return ps; + } + + stop() { + if (this._cliProcess && !this._cliProcess.kill()) { + logger.warn('Unable to abort Synthetics test process.'); + } + this._cliProcess = null; + } +} + +export const syntheticsManager = new SyntheticsManager(); From 069c6481b9d0b189117571f4e56b9b8a120b605a Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Tue, 7 Feb 2023 18:31:33 +0900 Subject: [PATCH 08/14] chore: tidy up --- common/types.d.ts | 5 +---- src/components/SaveCodeButton.test.tsx | 2 +- src/components/TestResult/TestResult.test.tsx | 2 +- src/helpers/test/{ipc.ts => mockApi.ts} | 0 src/hooks/useRecordingContext.test.ts | 2 +- src/hooks/useSyntheticsTest.test.tsx | 2 +- 6 files changed, 5 insertions(+), 8 deletions(-) rename src/helpers/test/{ipc.ts => mockApi.ts} (100%) diff --git a/common/types.d.ts b/common/types.d.ts index 19e57fb2..ed6cb668 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -117,7 +117,6 @@ export type GenerateCodeOptions = { }; export interface IElectronAPI { exportScript: (string) => Promise; - /* <-- for recordJourney */ recordJourney: (url: string) => Promise; stopRecording: () => void; pauseRecording: () => Promise; @@ -125,13 +124,11 @@ export interface IElectronAPI { onActionGenerated: ( callback: (event: IpcRendererEvent, actions: ActionContext[]) => void ) => () => void; - /* for recordJourney --> */ generateCode: (params: GenerateCodeOptions) => Promise; openExternalLink: (url: string) => Promise; - runTest: ( params: RunJourneyOptions, - callback: (_event: IpcRendererEvent, data: TestEvent) => void + callback: (event: IpcRendererEvent, data: TestEvent) => void ) => Promise; removeOnTestListener: () => void; } diff --git a/src/components/SaveCodeButton.test.tsx b/src/components/SaveCodeButton.test.tsx index 3834dce8..f4fc3d96 100644 --- a/src/components/SaveCodeButton.test.tsx +++ b/src/components/SaveCodeButton.test.tsx @@ -27,7 +27,7 @@ import { render } from '../helpers/test'; import { SaveCodeButton } from './SaveCodeButton'; import { createSteps } from '../../common/helper/test/createAction'; import { fireEvent, waitFor } from '@testing-library/react'; -import { getMockElectronApi } from '../helpers/test/ipc'; +import { getMockElectronApi } from '../helpers/test/mockApi'; describe('SaveCodeButton', () => { it('calls ipc on click', async () => { diff --git a/src/components/TestResult/TestResult.test.tsx b/src/components/TestResult/TestResult.test.tsx index 93bc07e3..49350958 100644 --- a/src/components/TestResult/TestResult.test.tsx +++ b/src/components/TestResult/TestResult.test.tsx @@ -26,7 +26,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import { createStep } from '../../../common/helper/test/createAction'; import { render } from '../../helpers/test'; -import { getMockElectronApi } from '../../helpers/test/ipc'; +import { getMockElectronApi } from '../../helpers/test/mockApi'; import { TestResult } from './TestResult'; describe('TestResult', () => { diff --git a/src/helpers/test/ipc.ts b/src/helpers/test/mockApi.ts similarity index 100% rename from src/helpers/test/ipc.ts rename to src/helpers/test/mockApi.ts diff --git a/src/hooks/useRecordingContext.test.ts b/src/hooks/useRecordingContext.test.ts index 2f974806..8eab5f4d 100644 --- a/src/hooks/useRecordingContext.test.ts +++ b/src/hooks/useRecordingContext.test.ts @@ -39,7 +39,7 @@ THE SOFTWARE. import { act, renderHook } from '@testing-library/react-hooks'; import { IElectronAPI, RecorderSteps } from '../../common/types'; import { RecordingStatus, Setter } from '../common/types'; -import { getMockElectronApi } from '../helpers/test/ipc'; +import { getMockElectronApi } from '../helpers/test/mockApi'; import { useRecordingContext } from './useRecordingContext'; describe('useRecordingContext', () => { diff --git a/src/hooks/useSyntheticsTest.test.tsx b/src/hooks/useSyntheticsTest.test.tsx index 46c12eea..730a8f4f 100644 --- a/src/hooks/useSyntheticsTest.test.tsx +++ b/src/hooks/useSyntheticsTest.test.tsx @@ -35,7 +35,7 @@ THE SOFTWARE. import React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; -import { getMockElectronApi } from '../helpers/test/ipc'; +import { getMockElectronApi } from '../helpers/test/mockApi'; import { TestContextWrapper } from '../helpers/test/render'; import { useSyntheticsTest } from './useSyntheticsTest'; import { IElectronAPI, RecorderSteps, Result } from '../../common/types'; From a7d933faf69247c667a80e0895266c6773088e3e Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Tue, 7 Feb 2023 18:34:48 +0900 Subject: [PATCH 09/14] chore: deletes useless comments --- electron/api/recordJourney.ts | 6 ++---- src/App.tsx | 1 - src/hooks/useRecordingContext.ts | 5 ----- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/electron/api/recordJourney.ts b/electron/api/recordJourney.ts index 9691078c..260c10f4 100644 --- a/electron/api/recordJourney.ts +++ b/electron/api/recordJourney.ts @@ -21,12 +21,11 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; -import type { BrowserContext } from 'playwright-core'; - import path from 'path'; import { EventEmitter, once } from 'events'; import { existsSync } from 'fs'; +import { BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'; +import type { BrowserContext } from 'playwright-core'; import logger from 'electron-log'; import { ActionInContext } from '../../common/types'; import { BrowserManager } from '../browserManager'; @@ -48,7 +47,6 @@ export async function recordJourney( // Listen to actions from Playwright recording session const actionsHandler = (actions: ActionInContext[]) => { - // ipcMain.callRenderer(browserWindow, 'change', { actions }); browserWindow.webContents.send('change', actions); }; actionListener.on('actions', actionsHandler); diff --git a/src/App.tsx b/src/App.tsx index 857de040..a12d1562 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,7 +91,6 @@ export default function App() { return generateMergedIR(prevSteps, nextSteps); }); }; - // ipc.answerMain('change', listener); const removeListener = electronAPI.onActionGenerated(listener); return removeListener; }, [electronAPI, setSteps]); diff --git a/src/hooks/useRecordingContext.ts b/src/hooks/useRecordingContext.ts index f45ed43d..5f66d1d8 100644 --- a/src/hooks/useRecordingContext.ts +++ b/src/hooks/useRecordingContext.ts @@ -50,11 +50,9 @@ export function useRecordingContext( } else if (recordingStatus === RecordingStatus.Recording) { setRecordingStatus(RecordingStatus.NotRecording); // Stop browser process - // ipc.send('stop'); electronAPI.stopRecording(); } else { setRecordingStatus(RecordingStatus.Recording); - // await ipc.callMain('record-journey', { url }); await electronAPI.recordJourney(url); setRecordingStatus(RecordingStatus.NotRecording); } @@ -67,7 +65,6 @@ export function useRecordingContext( // Depends on the result's context, because when we overwrite // a previous journey we need to discard its result status setResult(undefined); - // await ipc.callMain('record-journey', { url }); await electronAPI.recordJourney(url); setRecordingStatus(RecordingStatus.NotRecording); } @@ -77,10 +74,8 @@ export function useRecordingContext( if (recordingStatus === RecordingStatus.NotRecording) return; if (recordingStatus !== RecordingStatus.Paused) { setRecordingStatus(RecordingStatus.Paused); - // await ipc.callMain('set-mode', 'none'); await electronAPI.pauseRecording(); } else { - // await ipc.callMain('set-mode', 'recording'); await electronAPI.resumeRecording(); setRecordingStatus(RecordingStatus.Recording); } From 93d01fcb6a2a2dab40c1cab124b0a67ae71205dd Mon Sep 17 00:00:00 2001 From: Kyungeun Kim Date: Wed, 8 Feb 2023 13:09:17 +0900 Subject: [PATCH 10/14] chore: use simpler callback Co-authored-by: Justin Kambic --- electron/electron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/electron.ts b/electron/electron.ts index 431265f1..41e4f241 100644 --- a/electron/electron.ts +++ b/electron/electron.ts @@ -86,7 +86,7 @@ async function createMainWindow() { if (BrowserWindow.getAllWindows().length === 0) { const mainWindowEmitter = await createWindow(); const ipcListenerDestructor = setupListeners(mainWindowEmitter); - mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, () => ipcListenerDestructor()); + mainWindowEmitter.addListener(MainWindowEvent.MAIN_CLOSE, ipcListenerDestructor); createMenu(); } } From 25ba8e00f9f70025aca177e1db89dd90e050bb1b Mon Sep 17 00:00:00 2001 From: Kyungeun Kim Date: Wed, 8 Feb 2023 16:24:54 +0900 Subject: [PATCH 11/14] chore: apply suggestions from code review Co-authored-by: Justin Kambic --- electron/preload.ts | 1 - src/hooks/useSyntheticsTest.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/electron/preload.ts b/electron/preload.ts index 298a1ca0..48f8ceda 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -67,7 +67,6 @@ const electronAPI: IElectronAPI = { ipcRenderer.on('test-event', callback); await ipcRenderer.invoke('run-journey', params); }, - removeOnTestListener: () => { ipcRenderer.removeAllListeners('test-event'); }, diff --git a/src/hooks/useSyntheticsTest.ts b/src/hooks/useSyntheticsTest.ts index f7fe13ee..2bdb51fc 100644 --- a/src/hooks/useSyntheticsTest.ts +++ b/src/hooks/useSyntheticsTest.ts @@ -64,7 +64,6 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { dispatch(data); }; - // electronAPI.on('test-event', onTestEvent); try { const promise = electronAPI.runTest( From feedbc145cd1a51c29657f02e88a9d2da262bc96 Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Wed, 8 Feb 2023 17:24:50 +0900 Subject: [PATCH 12/14] chore: address feedback --- common/types.d.ts | 6 ++++-- electron/api/recordJourney.ts | 2 +- electron/preload.ts | 29 +++++++++------------------ src/App.tsx | 2 +- src/helpers/test/defaults.ts | 2 +- src/helpers/test/mockApi.ts | 2 +- src/hooks/useRecordingContext.test.ts | 5 +++-- src/hooks/useSyntheticsTest.ts | 1 - 8 files changed, 20 insertions(+), 29 deletions(-) diff --git a/common/types.d.ts b/common/types.d.ts index ed6cb668..db8d253c 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -115,15 +115,17 @@ export type GenerateCodeOptions = { actions: Steps; isProject: boolean; }; + +export type ActionGeneratedCallback = (event: IpcRendererEvent, actions: ActionContext[]) => void; export interface IElectronAPI { exportScript: (string) => Promise; recordJourney: (url: string) => Promise; stopRecording: () => void; pauseRecording: () => Promise; resumeRecording: () => Promise; - onActionGenerated: ( + addActionGeneratedListener( callback: (event: IpcRendererEvent, actions: ActionContext[]) => void - ) => () => void; + ): () => void; generateCode: (params: GenerateCodeOptions) => Promise; openExternalLink: (url: string) => Promise; runTest: ( diff --git a/electron/api/recordJourney.ts b/electron/api/recordJourney.ts index 260c10f4..1afebdad 100644 --- a/electron/api/recordJourney.ts +++ b/electron/api/recordJourney.ts @@ -47,7 +47,7 @@ export async function recordJourney( // Listen to actions from Playwright recording session const actionsHandler = (actions: ActionInContext[]) => { - browserWindow.webContents.send('change', actions); + browserWindow.webContents.send('actions-generated', actions); }; actionListener.on('actions', actionsHandler); diff --git a/electron/preload.ts b/electron/preload.ts index 48f8ceda..9ee85b04 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -21,14 +21,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { - ActionContext, - GenerateCodeOptions, - IElectronAPI, - RunJourneyOptions, - TestEvent, -} from '../common/types'; -import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; +import type { IElectronAPI } from '../common/types'; +import { contextBridge, ipcRenderer } from 'electron'; const electronAPI: IElectronAPI = { exportScript: async contents => { @@ -46,25 +40,20 @@ const electronAPI: IElectronAPI = { resumeRecording: async () => { await ipcRenderer.invoke('set-mode', 'recording'); }, - onActionGenerated: ( - callback: (_event: IpcRendererEvent, actions: ActionContext[]) => void - ): (() => void) => { - ipcRenderer.on('change', callback); + addActionGeneratedListener: listener => { + ipcRenderer.on('actions-generated', listener); return () => { - ipcRenderer.removeAllListeners('change'); + ipcRenderer.removeAllListeners('actions-generated'); }; }, - generateCode: async (params: GenerateCodeOptions) => { + generateCode: async params => { return ipcRenderer.invoke('actions-to-code', params); }, - openExternalLink: async (url: string) => { + openExternalLink: async url => { await ipcRenderer.invoke('open-external-link', url); }, - runTest: async ( - params: RunJourneyOptions, - callback: (_event: IpcRendererEvent, data: TestEvent) => void - ) => { - ipcRenderer.on('test-event', callback); + runTest: async (params, listener) => { + ipcRenderer.on('test-event', listener); await ipcRenderer.invoke('run-journey', params); }, removeOnTestListener: () => { diff --git a/src/App.tsx b/src/App.tsx index a12d1562..ffef1059 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,7 +91,7 @@ export default function App() { return generateMergedIR(prevSteps, nextSteps); }); }; - const removeListener = electronAPI.onActionGenerated(listener); + const removeListener = electronAPI.addActionGeneratedListener(listener); return removeListener; }, [electronAPI, setSteps]); diff --git a/src/helpers/test/defaults.ts b/src/helpers/test/defaults.ts index 49d00d05..87207499 100644 --- a/src/helpers/test/defaults.ts +++ b/src/helpers/test/defaults.ts @@ -88,7 +88,7 @@ export const getCommunicationContextDefaults = (): ICommunicationContext => ({ stopRecording: jest.fn(), pauseRecording: jest.fn(), resumeRecording: jest.fn(), - onActionGenerated: jest.fn(), + addActionGeneratedListener: jest.fn(), generateCode: jest.fn(), openExternalLink: jest.fn(), runTest: jest.fn(), diff --git a/src/helpers/test/mockApi.ts b/src/helpers/test/mockApi.ts index 42f5f9fb..6fd06640 100644 --- a/src/helpers/test/mockApi.ts +++ b/src/helpers/test/mockApi.ts @@ -31,7 +31,7 @@ export function getMockElectronApi(overrides?: Partial): IElectron stopRecording: jest.fn(), pauseRecording: jest.fn(), resumeRecording: jest.fn(), - onActionGenerated: jest.fn(), + addActionGeneratedListener: jest.fn(), generateCode: jest.fn(), openExternalLink: jest.fn(), runTest: jest.fn(), diff --git a/src/hooks/useRecordingContext.test.ts b/src/hooks/useRecordingContext.test.ts index 8eab5f4d..69f2cca6 100644 --- a/src/hooks/useRecordingContext.test.ts +++ b/src/hooks/useRecordingContext.test.ts @@ -68,8 +68,9 @@ describe('useRecordingContext', () => { }); it('should toggle the recording status when toggleRecording is called', async () => { + const url = 'https://elastic.co'; const renderHookResponse = renderHook(() => - useRecordingContext(electronApi, '', 0, setResult, setSteps) + useRecordingContext(electronApi, url, 0, setResult, setSteps) ); const recordingContext = renderHookResponse.result.current; @@ -77,7 +78,7 @@ describe('useRecordingContext', () => { renderHookResponse.rerender(); - expect(recordJourney).toHaveBeenCalledWith(''); + expect(recordJourney).toHaveBeenCalledWith(url); }); it('sets start over modal visible if a step exists', async () => { diff --git a/src/hooks/useSyntheticsTest.ts b/src/hooks/useSyntheticsTest.ts index 2bdb51fc..8c7eb4f2 100644 --- a/src/hooks/useSyntheticsTest.ts +++ b/src/hooks/useSyntheticsTest.ts @@ -64,7 +64,6 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { dispatch(data); }; - try { const promise = electronAPI.runTest( { From d15380a7d86cf4c2f80ee1455bbb780bcedcfb32 Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Thu, 9 Feb 2023 12:58:29 +0900 Subject: [PATCH 13/14] chore: define type for fn used as param --- common/types.d.ts | 12 ++++-------- src/App.tsx | 5 ++--- src/hooks/useSyntheticsTest.ts | 7 +++---- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/common/types.d.ts b/common/types.d.ts index db8d253c..42592d24 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -116,21 +116,17 @@ export type GenerateCodeOptions = { isProject: boolean; }; -export type ActionGeneratedCallback = (event: IpcRendererEvent, actions: ActionContext[]) => void; +export type ActionGeneratedListener = (event: IpcRendererEvent, actions: ActionContext[]) => void; +export type TestEventListener = (event: IpcRendererEvent, data: TestEvent) => void; export interface IElectronAPI { exportScript: (string) => Promise; recordJourney: (url: string) => Promise; stopRecording: () => void; pauseRecording: () => Promise; resumeRecording: () => Promise; - addActionGeneratedListener( - callback: (event: IpcRendererEvent, actions: ActionContext[]) => void - ): () => void; + addActionGeneratedListener(listener: ActionGeneratedListener): () => void; generateCode: (params: GenerateCodeOptions) => Promise; openExternalLink: (url: string) => Promise; - runTest: ( - params: RunJourneyOptions, - callback: (event: IpcRendererEvent, data: TestEvent) => void - ) => Promise; + runTest: (params: RunJourneyOptions, listener: TestEventListener) => Promise; removeOnTestListener: () => void; } diff --git a/src/App.tsx b/src/App.tsx index ffef1059..a06bcc23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,8 +49,7 @@ import { StyledComponentsEuiProvider } from './contexts/StyledComponentsEuiProvi import { ExportScriptFlyout } from './components/ExportScriptFlyout'; import { useRecordingContext } from './hooks/useRecordingContext'; import { StartOverWarningModal } from './components/StartOverWarningModal'; -import { ActionContext, RecorderSteps, Steps } from '../common/types'; -import type { IpcRendererEvent } from 'electron'; +import { ActionGeneratedListener, RecorderSteps, Steps } from '../common/types'; /** * This is the prescribed workaround to some internal EUI issues that occur @@ -85,7 +84,7 @@ export default function App() { useEffect(() => { // `actions` here is a set of `ActionInContext`s that make up a `Step` - const listener = (_event: IpcRendererEvent, actions: ActionContext[]) => { + const listener: ActionGeneratedListener = (_event, actions) => { setSteps((prevSteps: RecorderSteps) => { const nextSteps: Steps = generateIR([{ actions }]); return generateMergedIR(prevSteps, nextSteps); diff --git a/src/hooks/useSyntheticsTest.ts b/src/hooks/useSyntheticsTest.ts index 8c7eb4f2..f1d36770 100644 --- a/src/hooks/useSyntheticsTest.ts +++ b/src/hooks/useSyntheticsTest.ts @@ -22,11 +22,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { IpcRendererEvent } from 'electron'; import { useCallback, useContext, useEffect, useReducer, useState } from 'react'; import { getCodeFromActions, getCodeForFailedResult } from '../common/shared'; import { CommunicationContext } from '../contexts/CommunicationContext'; -import type { RecorderSteps, Result, TestEvent } from '../../common/types'; +import type { RecorderSteps, Result, TestEventListener } from '../../common/types'; import type { ITestContext } from '../contexts/TestContext'; import { resultReducer } from '../helpers/resultReducer'; @@ -60,7 +59,7 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { if (!isTestInProgress) { // destroy stale state dispatch({ data: undefined, event: 'override' }); - const onTestEvent = (_event: IpcRendererEvent, data: TestEvent) => { + const onTestEventListener: TestEventListener = (_event, data) => { dispatch(data); }; @@ -71,7 +70,7 @@ export function useSyntheticsTest(steps: RecorderSteps): ITestContext { code, isProject: false, }, - onTestEvent + onTestEventListener ); setIsTestInProgress(true); setIsResultFlyoutVisible(true); From 999eb28c16f3ce54793887de50a3635ca5c5f50c Mon Sep 17 00:00:00 2001 From: kyungeunni Date: Thu, 9 Feb 2023 13:27:21 +0900 Subject: [PATCH 14/14] fix: replace ipc to electronAPI --- src/components/ExportScriptFlyout/Flyout.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ExportScriptFlyout/Flyout.test.tsx b/src/components/ExportScriptFlyout/Flyout.test.tsx index 8358ff66..cd25b326 100644 --- a/src/components/ExportScriptFlyout/Flyout.test.tsx +++ b/src/components/ExportScriptFlyout/Flyout.test.tsx @@ -26,7 +26,7 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; import { ExportScriptFlyout } from './Flyout'; import { render } from '../../helpers/test'; -import { getMockIpc } from '../../helpers/test/ipc'; +import { getMockElectronApi } from '../../helpers/test/mockApi'; import { createSteps } from '../../../common/helper/test/createAction'; jest.mock('../../common/shared', () => ({ @@ -41,7 +41,7 @@ describe('ExportScriptFlyout', () => { const { getByText } = render(, { contextOverrides: { communication: { - ipc: getMockIpc(), + electronAPI: getMockElectronApi(), }, }, }); @@ -53,7 +53,7 @@ describe('ExportScriptFlyout', () => { const { findByText } = render(, { contextOverrides: { communication: { - ipc: getMockIpc(), + electronAPI: getMockElectronApi(), }, }, }); @@ -65,7 +65,7 @@ describe('ExportScriptFlyout', () => { const { getByText } = render(, { contextOverrides: { communication: { - ipc: getMockIpc(), + electronAPI: getMockElectronApi(), }, }, }); @@ -80,7 +80,7 @@ describe('ExportScriptFlyout', () => { { contextOverrides: { communication: { - ipc: getMockIpc(), + electronAPI: getMockElectronApi(), }, }, }