feat: enable islated utility process [INS-3378]

This commit is contained in:
George He
2023-12-02 16:46:21 +08:00
parent a5d843171a
commit efb20a69c4
16 changed files with 840 additions and 11 deletions

View File

@@ -1,5 +1,5 @@
// Read more about creating fixtures https://playwright.dev/docs/test-fixtures
import { ElectronApplication, test as baseTest, TraceMode } from '@playwright/test';
import { ElectronApplication, expect, Page, test as baseTest, TraceMode } from '@playwright/test';
import path from 'path';
import {
@@ -97,11 +97,31 @@ export const test = baseTest.extend<{
await electronApp.close();
},
page: async ({ app }, use) => {
const page = await app.firstWindow();
// getMainPage waits main renderer loaded
const getMainPage = async () => {
for (let i = 0; i < 100; i++) {
const wins = app.windows();
await page.waitForLoadState();
for (const page of wins) {
const title = await page.title();
if (title === 'Insomnia') {
return page;
}
}
await use(page);
await new Promise(resolve => setTimeout(resolve, 500));
}
return undefined;
};
const page = await getMainPage();
if (!page) {
expect(page).toBeDefined();
} else {
await page.waitForLoadState();
await use(page);
}
},
dataPath: async ({ }, use) => {
const insomniaDataPath = randomDataPath();

View File

@@ -0,0 +1,283 @@
import { expect } from '@playwright/test';
import { test } from '../../playwright/test';;
test.describe('test utility process', async () => {
const testCases = [
{
id: 'environment basic operations',
code: `
const bool2 = pm.environment.has('bool1');
const num2 = pm.environment.get('num1');
const str2 = pm.environment.get('str1');
// add & update
pm.environment.set('bool1', false);
pm.environment.set('num1', 11);
pm.environment.set('str1', 'strr');
pm.environment.set('bool2', bool2);
pm.environment.set('num2', num2);
pm.environment.set('str2', str2);
// toObject
const newObject = pm.environment.toObject();
pm.environment.set('newObject.bool', newObject.bool2);
pm.environment.set('newObject.num', newObject.num2);
pm.environment.set('newObject.str', newObject.str2);
// unset
pm.environment.set('willDelete', 11);
pm.environment.unset('willDelete');
// render
const vals = pm.environment.replaceIn('{{bool1}}-{{num1}}-{{str1}}');
pm.environment.set('rendered', vals);
`,
context: {
pm: {
environment: {
bool1: true,
num1: 1,
str1: 'str',
},
},
},
expectedResult: {
collectionVariables: {},
iterationData: {},
globals: {},
variables: {
bool1: false,
num1: 11,
str1: 'strr',
bool2: true,
num2: 1,
str2: 'str',
'newObject.bool': true,
'newObject.num': 1,
'newObject.str': 'str',
'rendered': 'false-11-strr',
},
environment: {
bool1: false,
num1: 11,
str1: 'strr',
bool2: true,
num2: 1,
str2: 'str',
'newObject.bool': true,
'newObject.num': 1,
'newObject.str': 'str',
'rendered': 'false-11-strr',
},
},
},
{
id: 'collectionVariables (baseEnvironment) basic operations',
code: `
const bool2 = pm.collectionVariables.has('bool1');
const num2 = pm.collectionVariables.get('num1');
const str2 = pm.collectionVariables.get('str1');
// add & update
pm.collectionVariables.set('bool1', false);
pm.collectionVariables.set('num1', 11);
pm.collectionVariables.set('str1', 'strr');
pm.collectionVariables.set('bool2', bool2);
pm.collectionVariables.set('num2', num2);
pm.collectionVariables.set('str2', str2);
// toObject
const newObject = pm.collectionVariables.toObject();
pm.collectionVariables.set('newObject.bool', newObject.bool2);
pm.collectionVariables.set('newObject.num', newObject.num2);
pm.collectionVariables.set('newObject.str', newObject.str2);
// unset
pm.collectionVariables.set('willDelete', 11);
pm.collectionVariables.unset('willDelete');
// render
const vals = pm.collectionVariables.replaceIn('{{bool1}}-{{num1}}-{{str1}}');
pm.collectionVariables.set('rendered', vals);
`,
context: {
pm: {
collectionVariables: {
bool1: true,
num1: 1,
str1: 'str',
},
},
},
expectedResult: {
environment: {},
variables: {
'bool1': false,
'bool2': true,
'newObject.bool': true,
'newObject.num': 1,
'newObject.str': 'str',
'num1': 11,
'num2': 1,
'rendered': 'false-11-strr',
'str1': 'strr',
'str2': 'str',
},
globals: {},
iterationData: {},
collectionVariables: {
bool1: false,
num1: 11,
str1: 'strr',
bool2: true,
num2: 1,
str2: 'str',
'newObject.bool': true,
'newObject.num': 1,
'newObject.str': 'str',
'rendered': 'false-11-strr',
},
},
},
{
id: 'variables basic operations',
code: `
const unexisting = pm.variables.has('VarNotExist');
const strFromGlobal = pm.variables.get('glb');
const strFromCollection = pm.variables.get('col');
const numFromEnv = pm.variables.get('num');
const strFromIter = pm.variables.get('str');
const rendered = pm.variables.replaceIn('{{bool}}-{{num}}-{{str}}');
// set local
pm.variables.set('strFromGlobal', strFromGlobal);
pm.variables.set('strFromCollection', strFromCollection);
pm.variables.set('numFromEnv', numFromEnv);
pm.variables.set('strFromIter', strFromIter);
pm.variables.set('rendered', rendered);
`,
context: {
pm: {
globals: {
bool: false,
num: 1,
glb: 'glb',
},
collectionVariables: {
num: 2,
col: 'col',
},
environment: {
num: 3,
str: 'env',
},
iterationData: {
str: 'iter',
},
},
},
expectedResult: {
globals: {
bool: false,
num: 1,
glb: 'glb',
},
collectionVariables: {
num: 2,
col: 'col',
},
environment: {
num: 3,
str: 'env',
},
iterationData: {
str: 'iter',
},
variables: {
strFromGlobal: 'glb',
strFromCollection: 'col',
numFromEnv: 3,
strFromIter: 'iter',
rendered: 'false-3-iter',
bool: false,
col: 'col',
glb: 'glb',
num: 3,
'str': 'iter',
},
},
},
{
id: 'simple test sendRequest and await/async',
code: `
let testResp;
try {
await new Promise(
resolve => {
pm.sendRequest(
'http://127.0.0.1:4010/pets/1',
(err, resp) => {
testResp = resp;
resolve();
}
);
}
);
} catch (e) {
pm.variables.set('error', e);
}
pm.variables.set('resp.code', testResp.code);
`,
context: {
pm: {},
},
expectedResult: {
globals: {},
iterationData: {},
variables: {
'resp.code': 200,
},
environment: {},
collectionVariables: {},
},
},
];
for (let i = 0; i < testCases.length; i++) {
const tc = testCases[i];
// tests begin here
test(tc.id, async ({ page: mainWindow }) => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
// action
await mainWindow?.evaluate(
async (tc: any) => {
window.postMessage(
{
action: 'message-event://utility.process/debug',
id: tc.id,
code: tc.code,
context: tc.context,
},
'*',
);
},
tc,
);
// assert
let localStorage;
// TODO: ideally call waitForEvent
for (let i = 0; i < 120; i++) {
localStorage = await mainWindow?.evaluate(() => window.localStorage);
expect(localStorage).toBeDefined();
if (localStorage[`test_result:${tc.id}`] || localStorage[`test_error:${tc.id}`]) {
break;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
if (localStorage) { // just for suppressing ts complaint
expect(JSON.parse(localStorage[`test_result:${tc.id}`])).toEqual(tc.expectedResult);
}
});
}
});

View File

@@ -6,3 +6,5 @@ src/main.min.js
src/main.min.js.map
src/preload.js
src/preload.js.map
src/renderers/utility-process/**/*.js
src/renderers/utility-process/**/*.js.map

View File

@@ -41,6 +41,26 @@ export default async function build(options: Options) {
format: 'cjs',
external: ['electron'],
});
const preloadUtilityProcess = esbuild.build({
entryPoints: ['./src/renderers/utility-process/preload.ts'],
outfile: path.join(outdir, 'renderers/utility-process/preload-utility-process.js'),
target: 'esnext',
bundle: true,
platform: 'node',
sourcemap: true,
format: 'cjs',
external: ['electron'],
});
const utilityProcess = esbuild.build({
entryPoints: ['./src/renderers/utility-process/index.ts'],
outfile: path.join(outdir, 'renderers/utility-process/utility-process.min.js'),
target: 'esnext',
bundle: true,
platform: 'browser',
sourcemap: true,
format: 'cjs',
external: [],
});
const main = esbuild.build({
entryPoints: ['./src/main.development.ts'],
outfile: path.join(outdir, 'main.min.js'),
@@ -56,7 +76,7 @@ export default async function build(options: Options) {
...Object.keys(builtinModules),
],
});
return Promise.all([main, preload]);
return Promise.all([main, preload, preloadUtilityProcess, utilityProcess]);
}
// Build if ran as a cli script

View File

@@ -25,7 +25,7 @@
"package": "npm run build:app && cross-env USE_HARD_LINKS=false electron-builder build --config electron-builder.config.js",
"start": "concurrently -n dev,app --kill-others \"npm run start:dev-server\" \"npm run start:electron\"",
"start:dev-server": "vite dev",
"start:electron": "cross-env NODE_ENV=development esr esbuild.main.ts && electron .",
"start:electron": "cross-env NODE_ENV=development esr esbuild.main.ts && electron --inspect=5858 .",
"test": "jest",
"electron:dev-build": "electron ./build/main.min.js",
"test:watch": "jest --watch",

View File

@@ -0,0 +1,10 @@
import { BrowserWindow, ipcMain } from 'electron';
// registerUtilityProcessPort broadcasts message ports to observer windows
export function registerUtilityProcessConsumer(consumerWindows: BrowserWindow[]) {
ipcMain.on('ipc://main/publish-port', ev => {
consumerWindows.forEach(win => {
win.webContents.postMessage('ipc://renderers/publish-port', null, ev.ports);
});
});
}

View File

@@ -16,6 +16,7 @@ import {
} from '../common/constants';
import { docsBase } from '../common/documentation';
import * as log from '../common/log';
import { registerUtilityProcessConsumer } from './ipc/message-channel';
import LocalStorage from './local-storage';
const { app, Menu, shell, dialog, clipboard, BrowserWindow } = electron;
@@ -26,7 +27,9 @@ const MINIMUM_WIDTH = 500;
const MINIMUM_HEIGHT = 400;
let newWindow: ElectronBrowserWindow | null = null;
let isolatedUtilityProcess: ElectronBrowserWindow | null = null;
const windows = new Set<ElectronBrowserWindow>();
const processes = new Set<ElectronBrowserWindow>();
let localStorage: LocalStorage | null = null;
interface Bounds {
@@ -40,6 +43,45 @@ export function init() {
initLocalStorage();
}
export function createIsolatedProcess(parent?: electron.BrowserWindow) {
isolatedUtilityProcess = new BrowserWindow({
parent,
show: false,
title: 'UtilityProcess',
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
preload: path.join(__dirname, 'renderers/utility-process/preload-utility-process.js'),
spellcheck: false,
},
});
const utilityProcessPath = path.resolve(__dirname, './renderers/utility-process/index.html');
const utilityProcessUrl = pathToFileURL(utilityProcessPath).href;
isolatedUtilityProcess.loadURL(utilityProcessUrl);
isolatedUtilityProcess.once('ready-to-show', () => {
if (isolatedUtilityProcess) {
isolatedUtilityProcess.show();
if (parent) {
parent.webContents.openDevTools();
}
isolatedUtilityProcess.webContents.openDevTools();
}
});
isolatedUtilityProcess?.on('closed', () => {
if (isolatedUtilityProcess) {
processes.delete(isolatedUtilityProcess);
isolatedUtilityProcess = processes.values().next().value || null;
}
});
return isolatedUtilityProcess;
}
export function createWindow() {
const { bounds, fullscreen, maximize } = getBounds();
const { x, y, width, height } = bounds;
@@ -445,10 +487,10 @@ export function createWindow() {
helpMenu.submenu?.push({
type: 'separator',
},
{
label: `${MNEMONIC_SYM}About`,
click: aboutMenuClickHandler,
});
{
label: `${MNEMONIC_SYM}About`,
click: aboutMenuClickHandler,
});
}
const developerMenu: MenuItemConstructorOptions = {
@@ -547,6 +589,11 @@ export function createWindow() {
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
windows.add(newWindow);
const isolatedProcess = createIsolatedProcess(newWindow);
processes.add(isolatedProcess);
registerUtilityProcessConsumer([newWindow]);
return newWindow;
}

View File

@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron';
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import { gRPCBridgeAPI } from './main/ipc/grpc';
import { CurlBridgeAPI } from './main/network/curl';
@@ -95,3 +95,13 @@ if (process.contextIsolated) {
window.shell = shell;
window.clipboard = clipboard;
}
// it is different from window.main.on, it requires events to pass ports
ipcRenderer.on('ipc://renderers/publish-port', async (ev: IpcRendererEvent) => {
const windowLoaded = new Promise(resolve => {
window.onload = resolve;
});
await windowLoaded;
window.postMessage({ action: 'message-event://renderers/publish-port' }, '*', ev.ports);
});

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="font-src 'self' data:; connect-src * data: api: insomnia-event-source:; default-src * insomnia://*; img-src blob: data: * insomnia://*; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src blob: data: mediastream: * insomnia://*;"
/>
<title>Utility Process</title>
</head>
<body>
<h1>Utility Process</h1>
<script src="./utility-process.min.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
import { initPm } from './inso-object';
const executeAction = 'message-port://utility.process/execute';
async function init() {
const channel = new MessageChannel();
channel.port1.onmessage = async (ev: MessageEvent) => {
const action = ev.data.action;
if (action === executeAction || action === 'message-port://utility.process/debug') {
try {
const getPm = new Function('pm', 'return pm;');
const rawPm = getPm(ev.data.options.context.pm);
const pm = initPm(rawPm);
const AsyncFunction = (async () => { }).constructor;
const func = AsyncFunction(
'pm',
// TODO: support require function
// TODO: support async/await
`
${ev.data.options.code};
return pm.toObject();
`
);
const result = await func(pm);
channel.port1.postMessage({
action: action === executeAction ? 'message-port://caller/respond' : 'message-port://caller/debug/respond',
id: action === executeAction ? undefined : ev.data.options.id,
result,
});
} catch (e) {
console.log(JSON.stringify(e));
channel.port1.postMessage({
action: action === executeAction ? 'message-port://caller/respond' : 'message-port://caller/debug/respond',
id: action === executeAction ? undefined : ev.data.options.id,
error: JSON.stringify(e),
});
}
} else {
console.error(`unknown action ${ev.data}`);
}
};
window.postMessage('message-event://preload/publish-port', '*', [channel.port2]);
}
init();

View File

@@ -0,0 +1,190 @@
import { getHttpRequestSender, getIntepolator, HttpRequestSender, PmHttpRequest, PmHttpResponse } from './static-modules';
class BaseKV {
private kvs = new Map<string, boolean | number | string>();
constructor(jsonObject: object | undefined) {
// TODO: currently it doesn't support getting nested field directly
this.kvs = new Map(Object.entries(jsonObject || {}));
}
has = (variableName: string) => {
return this.kvs.has(variableName);
};
get = (variableName: string) => {
return this.kvs.get(variableName);
};
set = (variableName: string, variableValue: boolean | number | string) => {
this.kvs.set(variableName, variableValue);
};
unset = (variableName: string) => {
this.kvs.delete(variableName);
};
clear = () => {
this.kvs.clear();
};
replaceIn = (template: string) => {
return getIntepolator().render(template, this.toObject());
};
toObject = () => {
return Object.fromEntries(this.kvs.entries());
};
}
class Environment extends BaseKV {
constructor(jsonObject: object | undefined) {
super(jsonObject);
}
}
class Variables {
// TODO: support vars for all levels
private globals: BaseKV;
private collection: BaseKV;
private environment: BaseKV;
private data: BaseKV;
private local: BaseKV;
constructor(
args: {
globals: BaseKV;
collection: BaseKV;
environment: BaseKV;
data: BaseKV;
local: BaseKV;
},
) {
this.globals = args.globals;
this.collection = args.collection;
this.environment = args.environment;
this.data = args.data;
this.local = args.local;
}
has = (variableName: string) => {
const globalsHas = this.globals.has(variableName);
const collectionHas = this.collection.has(variableName);
const environmentHas = this.environment.has(variableName);
const dataHas = this.data.has(variableName);
const localHas = this.local.has(variableName);
return globalsHas || collectionHas || environmentHas || dataHas || localHas;
};
get = (variableName: string) => {
let finalVal: boolean | number | string | object | undefined = undefined;
[
this.local,
this.data,
this.environment,
this.collection,
this.globals,
].forEach(vars => {
const value = vars.get(variableName);
if (!finalVal && value) {
finalVal = value;
}
});
return finalVal;
};
set = (variableName: string, variableValue: boolean | number | string) => {
this.local.set(variableName, variableValue);
};
replaceIn = (template: string) => {
const context = this.toObject();
return getIntepolator().render(template, context);
};
toObject = () => {
return [
this.globals,
this.collection,
this.environment,
this.data,
this.local,
].map(
vars => vars.toObject()
).reduce(
(ctx, obj) => ({ ...ctx, ...obj }),
{},
);
};
}
class InsomniaObject {
public globals: Environment;
public collectionVariables: Environment;
public environment: Environment;
public iterationData: Environment;
public variables: Variables;
private httpRequestSender: HttpRequestSender = getHttpRequestSender();
constructor(input: {
globals: Environment;
collectionVariables: Environment;
environment: Environment;
iterationData: Environment;
variables: Variables;
}) {
this.globals = input.globals;
this.collectionVariables = input.collectionVariables;
this.environment = input.environment;
this.iterationData = input.iterationData;
this.variables = input.variables;
}
toObject = () => {
return {
globals: this.globals.toObject(),
variables: this.variables.toObject(),
environment: this.environment.toObject(),
collectionVariables: this.collectionVariables.toObject(),
iterationData: this.iterationData.toObject(),
};
};
sendRequest = async (req: string | PmHttpRequest, callback: (e?: Error, resp?: PmHttpResponse) => void) => {
return await this.httpRequestSender.sendRequest(req, callback);
};
}
interface RawObject {
globals?: object;
environment?: object;
collectionVariables?: object;
iterationData?: object;
}
export function initPm(rawObj: RawObject) {
const globals = new Environment(rawObj.globals);
const environment = new Environment(rawObj.environment);
const collectionVariables = new Environment(rawObj.collectionVariables);
const iterationData = new Environment(rawObj.iterationData);
const local = new Environment({});
const variables = new Variables({
globals,
environment,
collection: collectionVariables,
data: iterationData,
local,
});
return new InsomniaObject({
globals,
environment,
collectionVariables,
iterationData,
variables,
});
};

View File

@@ -0,0 +1,7 @@
import { ipcRenderer } from 'electron';
window.onmessage = (ev: MessageEvent) => {
if (ev.data === 'message-event://preload/publish-port') {
ipcRenderer.postMessage('ipc://main/publish-port', null, [ev.ports[0]]);
}
};

View File

@@ -0,0 +1,82 @@
import { configure, type ConfigureOptions, type Environment as NunjuncksEnv } from 'nunjucks';
export class PmHttpRequest {
public name: string;
constructor(_: {}, name?: string) {
this.name = name || '';
}
}
export interface PmHttpResponse {
code: number;
}
// common modules
class PmHttpRequestSender {
constructor() { }
sendRequest = async (req: string | PmHttpRequest, callback: (e?: Error, resp?: PmHttpResponse) => void): Promise<void> => {
if (typeof (req) === 'string') {
// simple request
return fetch(req)
.then(rawResp => {
// TODO: init all response fields
const resp = {
code: rawResp.status,
};
callback(
undefined,
resp,
);
})
.catch(err => {
callback(err);
});
}
// TODO:
return;
};
}
export interface HttpRequestSender {
sendRequest: (req: string | PmHttpRequest, callback: (e?: Error, resp?: PmHttpResponse) => void) => Promise<void>;
}
const httpRequestSender = new PmHttpRequestSender();
export function getHttpRequestSender() {
return httpRequestSender;
}
class Intepolator {
private engine: NunjuncksEnv;
constructor(config: ConfigureOptions) {
this.engine = configure(config);
}
render = (template: string, context: object) => {
// TODO: handle timeout
// TODO: support plugin?
return this.engine.renderString(template, context);
};
}
const intepolator = new Intepolator({
autoescape: false,
// Don't escape HTML
throwOnUndefined: true,
// Strict mode
tags: {
blockStart: '{%',
blockEnd: '%}',
variableStart: '{{',
variableEnd: '}}',
commentStart: '{#',
commentEnd: '#}',
},
});
export function getIntepolator() {
return intepolator;
}

View File

@@ -35,6 +35,7 @@ import { Migrate } from './routes/onboarding.migrate';
import { shouldOrganizationsRevalidate } from './routes/organization';
import Root from './routes/root';
import { initializeSentry } from './sentry';
import { getWindowMessageHandler } from './window-message-handlers';
const Organization = lazy(() => import('./routes/organization'));
const Project = lazy(() => import('./routes/project'));
@@ -49,6 +50,9 @@ initializeLogging();
document.body.setAttribute('data-platform', process.platform);
document.title = getProductName();
const windowMessageHandler = getWindowMessageHandler();
windowMessageHandler.start();
try {
// In order to run playwight tests that simulate a logged in user
// we need to inject state into localStorage

View File

@@ -0,0 +1,89 @@
type MessageHandler = (ev: MessageEvent) => Promise<void>;
class WindowMessageHandler {
private utilityProcessPort: MessagePort | undefined;
private actionHandlers: Map<string, MessageHandler> = new Map();
constructor() { }
publishPortHandler = async (ev: MessageEvent) => {
if (ev.ports.length === 0) {
console.error('no port is found in the publishing port event');
return;
}
this.utilityProcessPort = ev.ports[0];
this.utilityProcessPort.onmessage = ev => {
if (ev.data.action === 'message-port://caller/respond') {
// TODO: hook to UI and display result
console.log('[main] result from utility process:', ev.data.result);
} else if (ev.data.action === 'message-port://caller/debug/respond') {
if (ev.data.result) {
window.localStorage.setItem(`test_result:${ev.data.id}`, JSON.stringify(ev.data.result));
} else {
window.localStorage.setItem(`test_error:${ev.data.id}`, JSON.stringify(ev.data.error));
}
} else {
console.error(`unknown action ${ev}`);
}
};
};
debugEventHandler = async (ev: MessageEvent) => {
if (!this.utilityProcessPort) {
console.error('utility process port is not inited');
return;
}
this.utilityProcessPort.postMessage({
action: 'message-port://utility.process/debug',
options: {
id: ev.data.id,
code: ev.data.code,
context: ev.data.context,
},
});
};
// startUtilityProcessHandler = async (ev: MessageEvent) => {
// };
// TODO: registerMessagePortEventHandler
register = (actionName: string, handler: MessageHandler) => {
this.actionHandlers.set(actionName, handler);
};
start = () => {
this.register('message-event://renderers/publish-port', this.publishPortHandler);
this.register('message-event://utility.process/debug', this.debugEventHandler);
window.onmessage = (ev: MessageEvent) => {
const action = ev.data.action;
if (!action) {
// could be react events
return;
}
const handler = this.actionHandlers.get(action);
if (!handler) {
console.error(`no handler is found for action ${action}`);
return;
}
handler(ev);
};
};
stop = () => {
this.actionHandlers.clear();
};
}
const windowMessageHandler = new WindowMessageHandler();
export function getWindowMessageHandler() {
return windowMessageHandler;
}