From 62750bb04442bb08669cddd635a67c4a1ed449ef Mon Sep 17 00:00:00 2001 From: George He Date: Wed, 3 Jan 2024 16:40:03 +0800 Subject: [PATCH] fix: pick improvements from the downstream PR --- .../tests/smoke/pre-request-script.test.ts | 161 +++++++++--------- .../src/main/ipc/hidden-browser-window.ts | 10 +- packages/insomnia/src/main/window-utils.ts | 41 ++++- packages/insomnia/src/preload.ts | 1 + .../renderers/hidden-browser-window/index.ts | 73 ++++---- .../hidden-browser-window/inso-object.ts | 4 +- .../src/ui/window-message-handlers.ts | 100 ++++++++++- 7 files changed, 264 insertions(+), 126 deletions(-) diff --git a/packages/insomnia-smoke-test/tests/smoke/pre-request-script.test.ts b/packages/insomnia-smoke-test/tests/smoke/pre-request-script.test.ts index 9d91531c16..35a043e239 100644 --- a/packages/insomnia-smoke-test/tests/smoke/pre-request-script.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/pre-request-script.test.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test'; +import { ScriptError } from 'insomnia/src/ui/window-message-handlers'; -import { test } from '../../playwright/test';; +import { test } from '../../playwright/test'; async function waitForTrue(timeout: number, func: () => Promise) { const pollInterval = 500; @@ -16,7 +17,76 @@ async function waitForTrue(timeout: number, func: () => Promise) { } } -test.describe('test pre-request script execution', async () => { +async function runTests(testCases: { + id: string; + code: string; + context: object; + expectedResult: any; +}[]) { + for (let i = 0; i < testCases.length; i++) { + const tc = testCases[i]; + + // tests begin here + test(tc.id, async ({ app, page: mainWindow }) => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + + // start the sandbox + await mainWindow?.evaluate( + async () => { + // it suppresses the type checking error + const caller = window as unknown as { hiddenBrowserWindow: { start: () => void } }; + if (caller.hiddenBrowserWindow) { + caller.hiddenBrowserWindow.start(); + } + }, + ); + + // execute the command + await mainWindow?.evaluate( + async (tc: any) => { + window.postMessage( + { + action: 'message-event://hidden.browser-window/debug', + id: tc.id, + code: tc.code, + context: tc.context, + }, + '*', + ); + }, + tc, + ); + + // verify + let localStorage; + + await waitForTrue(60000, async () => { + localStorage = await mainWindow?.evaluate(() => window.localStorage); + expect(localStorage).toBeDefined(); + + return localStorage[`test_result:${tc.id}`] || localStorage[`test_error:${tc.id}`]; + }); + + expect(localStorage).toBeDefined(); // or no output is found + + if (localStorage) { // just for suppressing ts complaint + const result = localStorage[`test_result:${tc.id}`]; + const error = localStorage[`test_error:${tc.id}`]; + + if (result) { + expect(JSON.parse(result)).toEqual(tc.expectedResult); + } else { + const scriptError = JSON.parse(error) as ScriptError; + expect(scriptError.message).toEqual(tc.expectedResult.message); + // TODO: stack field is not checked as its content is complex + } + } + + }); + } +} + +test.describe('basic operations', async () => { const testCases = [ { @@ -277,6 +347,13 @@ test.describe('test pre-request script execution', async () => { }, }, }, + ]; + + await runTests(testCases); +}); + +test.describe('unhappy paths', async () => { + const testCases = [ { id: 'execution timeout (default timeout 3s)', code: ` @@ -292,90 +369,16 @@ test.describe('test pre-request script execution', async () => { { id: 'invalid result is returned', code: ` - resolve(); + return; `, context: { insomnia: {}, }, expectedResult: { - message: 'result is invalid, probably custom value is returned', + message: 'result is invalid, null or custom value may be returned', }, }, ]; - for (let i = 0; i < testCases.length; i++) { - const tc = testCases[i]; - - // tests begin here - test(tc.id, async ({ app, page: mainWindow }) => { - test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); - - const originalWindowCount = app.windows().length; - - // start the sandbox - await mainWindow?.evaluate( - async () => { - // it suppresses the type checking error - const caller = window as unknown as { hiddenBrowserWindow: { start: () => void } }; - if (caller.hiddenBrowserWindow) { - caller.hiddenBrowserWindow.start(); - } - }, - ); - - // waiting for the hidden browser ready - await waitForTrue(60000, async () => { - const windows = app.windows(); - - if (windows.length > originalWindowCount) { - for (const page of windows) { - if (await page.title() === 'Hidden Browser Window') { - await page.waitForLoadState(); - return true; - } - } - } - - return false; - }); - - // execute the command - await mainWindow?.evaluate( - async (tc: any) => { - window.postMessage( - { - action: 'message-event://hidden.browser-window/debug', - id: tc.id, - code: tc.code, - context: tc.context, - }, - '*', - ); - }, - tc, - ); - - // verify - let localStorage; - - await waitForTrue(60000, async () => { - localStorage = await mainWindow?.evaluate(() => window.localStorage); - expect(localStorage).toBeDefined(); - - return localStorage[`test_result:${tc.id}`] || localStorage[`test_error:${tc.id}`]; - }); - - if (localStorage) { // just for suppressing ts complaint - const result = localStorage[`test_result:${tc.id}`]; - const error = localStorage[`test_error:${tc.id}`]; - - if (result) { - expect(JSON.parse(result)).toEqual(tc.expectedResult); - } else { - expect(JSON.parse(error)).toEqual(tc.expectedResult); - } - } - - }); - } + await runTests(testCases); }); diff --git a/packages/insomnia/src/main/ipc/hidden-browser-window.ts b/packages/insomnia/src/main/ipc/hidden-browser-window.ts index d8d6c308f2..320c598b86 100644 --- a/packages/insomnia/src/main/ipc/hidden-browser-window.ts +++ b/packages/insomnia/src/main/ipc/hidden-browser-window.ts @@ -1,9 +1,10 @@ import { BrowserWindow, ipcMain } from 'electron'; -import { createHiddenBrowserWindow } from '../window-utils'; +import { createHiddenBrowserWindow, stopHiddenBrowserWindow } from '../window-utils'; export interface HiddenBrowserWindowAPI { start: () => void; + stop: () => void; } // registerHiddenBrowserWindowConsumer broadcasts message ports to observer windows @@ -16,7 +17,10 @@ export function registerHiddenBrowserWindowConsumer(consumerWindows: BrowserWind } export function registerHiddenBrowserWindowController() { - ipcMain.handle('ipc://main/hidden-browser-window/start', () => { - createHiddenBrowserWindow(); + ipcMain.handle('ipc://main/hidden-browser-window/start', async () => { + await createHiddenBrowserWindow(); + }); + ipcMain.handle('ipc://main/hidden-browser-window/stop', () => { + stopHiddenBrowserWindow(); }); } diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 5d08dbc1e0..6a0994c00c 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -16,6 +16,7 @@ import { } from '../common/constants'; import { docsBase } from '../common/documentation'; import * as log from '../common/log'; +import { invariant } from '../utils/invariant'; import { registerHiddenBrowserWindowConsumer, registerHiddenBrowserWindowController } from './ipc/hidden-browser-window'; import LocalStorage from './local-storage'; @@ -29,7 +30,7 @@ const MINIMUM_HEIGHT = 400; let newWindow: ElectronBrowserWindow | null = null; let hiddenBrowserWindow: ElectronBrowserWindow | null = null; const windows = new Set(); -const processes = new Set(); +const processes = new Map(); let localStorage: LocalStorage | null = null; interface Bounds { @@ -43,7 +44,25 @@ export function init() { initLocalStorage(); } -export function createHiddenBrowserWindow() { +export async function createHiddenBrowserWindow() { + if (hiddenBrowserWindow) { + await new Promise(resolve => { + invariant(hiddenBrowserWindow, 'hiddenBrowserWindow is running'); + + // overwrite the closed handler + hiddenBrowserWindow.on('closed', () => { + if (hiddenBrowserWindow) { + console.log('[hidden window] restarting hidden browser window:', hiddenBrowserWindow.id); + processes.delete(hiddenBrowserWindow.id); + hiddenBrowserWindow = null; + } + resolve(); + }); + + stopHiddenBrowserWindow(); + }); + } + hiddenBrowserWindow = new BrowserWindow({ show: false, title: 'HiddenBrowserWindow', @@ -60,20 +79,30 @@ export function createHiddenBrowserWindow() { const hiddenBrowserWindowPath = path.resolve(__dirname, './renderers/hidden-browser-window/index.html'); const hiddenBrowserWindowUrl = process.env.HIDDEN_BROWSER_WINDOW_URL || pathToFileURL(hiddenBrowserWindowPath).href; hiddenBrowserWindow.loadURL(hiddenBrowserWindowUrl); - console.log('[main] loading hidden browser window:', process.env.HIDDEN_BROWSER_WINDOW_URL, pathToFileURL(hiddenBrowserWindowPath).href); + + console.log( + `[hidden window] starting hidden browser window: ${hiddenBrowserWindow.id}`, + process.env.HIDDEN_BROWSER_WINDOW_URL, + pathToFileURL(hiddenBrowserWindowPath).href, + ); hiddenBrowserWindow?.on('closed', () => { if (hiddenBrowserWindow) { - processes.delete(hiddenBrowserWindow); - hiddenBrowserWindow = processes.values().next().value || null; + console.log('[hidden window] closing hidden browser window: ', hiddenBrowserWindow.id); + processes.delete(hiddenBrowserWindow.id); + hiddenBrowserWindow = null; } }); - processes.add(hiddenBrowserWindow); + processes.set(hiddenBrowserWindow.id, hiddenBrowserWindow); return hiddenBrowserWindow; } +export function stopHiddenBrowserWindow() { + hiddenBrowserWindow?.close(); +} + export function createWindow() { const { bounds, fullscreen, maximize } = getBounds(); const { x, y, width, height } = bounds; diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index 762cf04d72..01c882ff6c 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -89,6 +89,7 @@ const clipboard: Window['clipboard'] = { const hiddenBrowserWindow: Window['hiddenBrowserWindow'] = { start: () => ipcRenderer.invoke('ipc://main/hidden-browser-window/start'), + stop: () => ipcRenderer.invoke('ipc://main/hidden-browser-window/stop'), }; if (process.contextIsolated) { diff --git a/packages/insomnia/src/renderers/hidden-browser-window/index.ts b/packages/insomnia/src/renderers/hidden-browser-window/index.ts index ef18764268..2c931a0727 100644 --- a/packages/insomnia/src/renderers/hidden-browser-window/index.ts +++ b/packages/insomnia/src/renderers/hidden-browser-window/index.ts @@ -1,18 +1,19 @@ -import { initGlobalObject } from './inso-object'; +import { initGlobalObject, InsomniaObject } from './inso-object'; const ErrorTimeout = 'executing script timeout'; -const ErrorInvalidResult = 'result is invalid, probably custom value is returned'; +const ErrorInvalidResult = 'result is invalid, null or custom value may be returned'; const executeAction = 'message-channel://hidden.browser-window/execute'; async function init() { const channel = new MessageChannel(); + channel.port1.onmessage = async (ev: MessageEvent) => { const action = ev.data.action; const timeout = ev.data.timeout ? ev.data.timeout : 3000; - if (action === executeAction || action === 'message-channel://hidden.browser-window/debug') { - try { + try { + if (action === executeAction || action === 'message-channel://hidden.browser-window/debug') { const getRawGlobalObject = new Function('insomnia', 'return insomnia;'); const rawObject = getRawGlobalObject(ev.data.options.context.insomnia); const insomniaObject = initGlobalObject(rawObject); @@ -22,45 +23,59 @@ async function init() { 'insomnia', // if possible, avoid adding code to the following part ` - return new Promise(async (resolve, reject) => { - const $ = insomnia; - const pm = insomnia; - const alertTimeout = () => reject({ message: '${ErrorTimeout}:${timeout}ms' }); - const timeoutChecker = setTimeout(alertTimeout, ${timeout}); + const $ = insomnia, pm = insomnia; + ${ev.data.options.code}; - ${ev.data.options.code}; - - clearTimeout(timeoutChecker); - resolve(insomnia.toObject()); - }); + return insomnia; ` ); - const result = await executeScript(insomniaObject); - if (!result) { - throw { message: ErrorInvalidResult }; - } + const result = await new Promise(async (resolve, reject) => { + const alertTimeout = () => reject({ message: `${ErrorTimeout}:${timeout}ms` }); + const timeoutChecker = setTimeout(alertTimeout, timeout); + + try { + const insoObject = await executeScript(insomniaObject); + clearTimeout(timeoutChecker); + if (insoObject instanceof InsomniaObject) { + resolve(insoObject.toObject()); + } else { + throw { message: ErrorInvalidResult }; + } + } catch (e) { + reject(e); + } + }); channel.port1.postMessage({ action: action === executeAction ? 'message-channel://caller/respond' : 'message-channel://caller/debug/respond', - id: action === executeAction ? undefined : ev.data.options.id, + id: ev.data.options.id, result, }); - } catch (e) { - const message = e.message; - - channel.port1.postMessage({ - action: action === executeAction ? 'message-channel://caller/respond' : 'message-channel://caller/debug/respond', - id: action === executeAction ? undefined : ev.data.options.id, - error: { message: message || 'unknown error' }, - }); + } else { + console.error(`unknown action ${ev.data}`); } - } else { - console.error(`unknown action ${ev.data}`); + } catch (e) { + channel.port1.postMessage({ + action: action === executeAction ? 'message-channel://caller/respond' : 'message-channel://caller/debug/respond', + id: ev.data.options.id, + error: { + message: e.message || 'unknown error', + stack: e.stack, + }, + }); + } finally { + } }; window.postMessage('message-event://preload/publish-port', '*', [channel.port2]); + + window.onbeforeunload = () => { + channel.port1.postMessage({ + action: 'message-channel://consumers/close', + }); + }; } init(); diff --git a/packages/insomnia/src/renderers/hidden-browser-window/inso-object.ts b/packages/insomnia/src/renderers/hidden-browser-window/inso-object.ts index 970822c773..634c03edf1 100644 --- a/packages/insomnia/src/renderers/hidden-browser-window/inso-object.ts +++ b/packages/insomnia/src/renderers/hidden-browser-window/inso-object.ts @@ -154,7 +154,7 @@ class Variables { }; } -class InsomniaObject { +export class InsomniaObject { public globals: Environment; public collectionVariables: Environment; public environment: Environment; @@ -190,7 +190,7 @@ class InsomniaObject { }; } -interface RawObject { +export interface RawObject { globals?: object; environment?: object; collectionVariables?: object; diff --git a/packages/insomnia/src/ui/window-message-handlers.ts b/packages/insomnia/src/ui/window-message-handlers.ts index ec5c14c4df..5b06a72359 100644 --- a/packages/insomnia/src/ui/window-message-handlers.ts +++ b/packages/insomnia/src/ui/window-message-handlers.ts @@ -1,9 +1,26 @@ +import { RawObject } from '../renderers/hidden-browser-window/inso-object'; type MessageHandler = (ev: MessageEvent) => Promise; +export interface ScriptError { + message: string; + stack: string; +} + +interface ScriptResultResolver { + id: string; + resolve: (value: RawObject) => void; + reject: (error: ScriptError) => void; +} + +// WindowMessageHandler handles entities in followings domains: +// - handle window message events +// - handle message port events +// - trigger message callbacks class WindowMessageHandler { private hiddenBrowserWindowPort: MessagePort | undefined; private actionHandlers: Map = new Map(); + private scriptResultResolvers: ScriptResultResolver[] = []; constructor() { } @@ -17,8 +34,28 @@ class WindowMessageHandler { this.hiddenBrowserWindowPort.onmessage = ev => { if (ev.data.action === 'message-channel://caller/respond') { - // TODO: hook to UI and display result - console.log('[main] result from hidden browser window:', ev.data.result); + if (!ev.data.id) { + console.error('id is not specified in the executing script response message'); + return; + } + + const callbackIndex = this.scriptResultResolvers. + findIndex(callback => callback.id === ev.data.id); + if (callbackIndex < 0) { + console.error(`id(${ev.data.id}) is not found in the callback list`); + return; + } + + if (ev.data.result) { + this.scriptResultResolvers[callbackIndex].resolve(ev.data.result); + } else if (ev.data.error) { + this.scriptResultResolvers[callbackIndex].reject(ev.data.error); + } else { + console.error('no data found in the message port response'); + } + + // skip previous ones for keeping it simple + this.scriptResultResolvers = this.scriptResultResolvers.slice(callbackIndex + 1); } else if (ev.data.action === 'message-channel://caller/debug/respond') { if (ev.data.result) { window.localStorage.setItem(`test_result:${ev.data.id}`, JSON.stringify(ev.data.result)); @@ -27,20 +64,41 @@ class WindowMessageHandler { window.localStorage.setItem(`test_error:${ev.data.id}`, JSON.stringify(ev.data.error)); console.error(ev.data.error); } + } else if (ev.data.action === 'message-channel://consumers/close') { + this.hiddenBrowserWindowPort?.close(); + this.hiddenBrowserWindowPort = undefined; + console.log('[hidden win] hidden browser window port is closed'); } else { console.error(`unknown action ${ev}`); } }; }; + waitUntilHiddenBrowserWindowReady = async () => { + window.hiddenBrowserWindow.start(); + + // TODO: find a better way to wait for hidden browser window ready + // the hiddenBrowserWindow may be still in starting + // this is relatively simpler than receiving a 'ready' message from hidden browser window + for (let i = 0; i < 100; i++) { + if (this.hiddenBrowserWindowPort) { + break; + } else { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + console.error('the hidden window is still not ready'); + }; + debugEventHandler = async (ev: MessageEvent) => { if (!this.hiddenBrowserWindowPort) { - console.error('hidden browser window port is not inited'); - return; + console.error('hidden browser window port is not inited, restarting'); + await this.waitUntilHiddenBrowserWindowReady(); } console.info('sending script to hidden browser window'); - this.hiddenBrowserWindowPort.postMessage({ + this.hiddenBrowserWindowPort?.postMessage({ action: 'message-channel://hidden.browser-window/debug', options: { id: ev.data.id, @@ -55,6 +113,8 @@ class WindowMessageHandler { }; start = () => { + window.hiddenBrowserWindow.start(); + this.register('message-event://renderers/publish-port', this.publishPortHandler); this.register('message-event://hidden.browser-window/debug', this.debugEventHandler); @@ -79,8 +139,34 @@ class WindowMessageHandler { }; }; - stop = () => { - this.actionHandlers.clear(); + runPreRequestScript = async ( + id: string, + code: string, + context: object, + ): Promise => { + if (!this.hiddenBrowserWindowPort) { + console.error('hidden browser window port is not inited, restarting'); + await this.waitUntilHiddenBrowserWindowReady(); + } + + const promise = new Promise((resolve, reject) => { + this.scriptResultResolvers.push({ + id, + resolve, + reject, + }); + }); + + this.hiddenBrowserWindowPort?.postMessage({ + action: 'message-channel://hidden.browser-window/execute', + options: { + id, + code, + context, + }, + }); + + return promise; }; }