feat: Enhance Electron test harness with preload script and CSP configuration
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' blob: file:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; font-src 'self' data:;" />
|
||||||
<title>@paarrot/plugin-manager — Electron Test</title>
|
<title>@paarrot/plugin-manager — Electron Test</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ const path = require('path');
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the main application window.
|
* Creates the main application window.
|
||||||
* nodeIntegration is enabled so the renderer can use require() / dynamic import()
|
* Uses a preload bridge so the renderer can stay in the safer default context.
|
||||||
* directly against the local node_modules — appropriate for a local test harness.
|
|
||||||
*/
|
*/
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
@@ -12,8 +11,10 @@ function createWindow() {
|
|||||||
height: 800,
|
height: 800,
|
||||||
backgroundColor: '#1a1a2e',
|
backgroundColor: '#1a1a2e',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: false,
|
sandbox: false,
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1
test/electron/package-lock.json
generated
1
test/electron/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"name": "@paarrot/plugin-manager",
|
"name": "@paarrot/plugin-manager",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"rimraf": "^5.0.0",
|
"rimraf": "^5.0.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
31
test/electron/preload.js
Normal file
31
test/electron/preload.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { contextBridge } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
const pluginManagerBrowserEntryPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'node_modules',
|
||||||
|
'@paarrot',
|
||||||
|
'plugin-manager',
|
||||||
|
'dist',
|
||||||
|
'index.browser.js'
|
||||||
|
);
|
||||||
|
const examplePluginPath = path.join(__dirname, 'plugins', 'example-plugin', 'index.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the small Node-backed surface area the test harness needs while the
|
||||||
|
* renderer stays in an isolated browser context.
|
||||||
|
*/
|
||||||
|
contextBridge.exposeInMainWorld('electronTestApi', {
|
||||||
|
getHarnessBootstrap() {
|
||||||
|
return {
|
||||||
|
pluginManagerEntryUrl: pathToFileURL(pluginManagerBrowserEntryPath).href,
|
||||||
|
examplePluginPath,
|
||||||
|
examplePluginCode: fs.readFileSync(examplePluginPath, 'utf-8'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readTextFile(filePath) {
|
||||||
|
return fs.promises.readFile(filePath, 'utf-8');
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -5,49 +5,83 @@
|
|||||||
* then loads the example plugin from disk and demonstrates every API surface.
|
* then loads the example plugin from disk and demonstrates every API surface.
|
||||||
*/
|
*/
|
||||||
(async () => {
|
(async () => {
|
||||||
const { pathToFileURL } = require('url');
|
const testApi = window.electronTestApi;
|
||||||
const path = require('path');
|
if (!testApi) {
|
||||||
const fs = require('fs');
|
throw new Error('Electron test preload API is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
examplePluginCode,
|
||||||
|
examplePluginPath,
|
||||||
|
pluginManagerEntryUrl,
|
||||||
|
} = testApi.getHarnessBootstrap();
|
||||||
|
|
||||||
// Resolve the plugin-manager dist entry using a file:// URL so Electron can
|
|
||||||
// import an ESM module located inside node_modules.
|
|
||||||
const pmEntry = path.join(
|
|
||||||
__dirname, 'node_modules', '@paarrot', 'plugin-manager', 'dist', 'index.js'
|
|
||||||
);
|
|
||||||
const {
|
const {
|
||||||
PluginMarketplaceClient,
|
PluginMarketplaceClient,
|
||||||
PluginMarketplaceManager,
|
PluginMarketplaceManager,
|
||||||
PluginRegistry,
|
PluginRegistry,
|
||||||
createPluginContext,
|
createPluginContext,
|
||||||
} = await import(pathToFileURL(pmEntry).href);
|
} = await import(pluginManagerEntryUrl);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Module loader — supports both CJS (module.exports) and ESM (export default)
|
// Module loader — supports both CJS (module.exports) and ESM (export default)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports arbitrary module source through a blob URL so CSP can remain free
|
||||||
|
* of unsafe-eval while still supporting test plugins loaded from strings.
|
||||||
|
* @param {string} source
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
async function importModuleFromSource(source) {
|
||||||
|
const blob = new Blob([source], { type: 'text/javascript' });
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await import(blobUrl);
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluates plugin source code and returns the raw module exports.
|
* Evaluates plugin source code and returns the raw module exports.
|
||||||
* Tries CJS eval first; on SyntaxError falls back to ESM data-URL import.
|
* Tries CommonJS first by wrapping the code as ESM, then falls back to native
|
||||||
|
* ESM import when the source already uses module syntax.
|
||||||
* @param {string} code
|
* @param {string} code
|
||||||
* @returns {Promise<object>}
|
* @returns {Promise<object>}
|
||||||
*/
|
*/
|
||||||
async function evalPluginCode(code) {
|
async function evalPluginCode(code) {
|
||||||
try {
|
try {
|
||||||
const m = { exports: {} };
|
const commonJsModule = await importModuleFromSource(
|
||||||
// eslint-disable-next-line no-new-func
|
`const module = { exports: {} };\nconst exports = module.exports;\n${code}\nexport default module.exports;\n`
|
||||||
new Function('module', 'exports', code)(m, m.exports);
|
);
|
||||||
const result = m.exports?.default ?? m.exports;
|
const result = commonJsModule.default;
|
||||||
if (result && typeof result === 'object') return result;
|
if (result && typeof result === 'object') {
|
||||||
// Fall through to ESM if exports came back empty
|
return result;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof SyntaxError)) throw err;
|
if (!(err instanceof SyntaxError)) {
|
||||||
// SyntaxError means ESM — fall through below
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESM path — import via data URL so the engine handles module syntax natively
|
const esmModule = await importModuleFromSource(code);
|
||||||
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`;
|
return esmModule.default ?? esmModule;
|
||||||
const mod = await import(dataUrl);
|
}
|
||||||
return mod.default ?? mod;
|
|
||||||
|
/**
|
||||||
|
* Infers a plugin id from an index.js path without relying on Node path APIs.
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function inferPluginId(filePath) {
|
||||||
|
const segments = filePath.split(/[\\/]+/).filter(Boolean);
|
||||||
|
if (segments.length < 2) {
|
||||||
|
return 'plugin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments[segments.length - 2];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,9 +196,8 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Load the example plugin from disk (CJS eval — same pattern as cinny)
|
// Load the example plugin from disk (CJS eval — same pattern as cinny)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const pluginPath = path.join(__dirname, 'plugins', 'example-plugin', 'index.js');
|
const pluginPath = examplePluginPath;
|
||||||
const pluginCode = fs.readFileSync(pluginPath, 'utf-8');
|
const plugin = await evalPluginCode(examplePluginCode);
|
||||||
const plugin = await evalPluginCode(pluginCode);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Simple in-process event emitter (stands in for a real client like Matrix)
|
// Simple in-process event emitter (stands in for a real client like Matrix)
|
||||||
@@ -387,7 +420,7 @@
|
|||||||
*/
|
*/
|
||||||
async function loadPluginFromPath(filePath) {
|
async function loadPluginFromPath(filePath) {
|
||||||
try {
|
try {
|
||||||
const code = fs.readFileSync(filePath, 'utf-8');
|
const code = await testApi.readTextFile(filePath);
|
||||||
const raw = await evalPluginCode(code);
|
const raw = await evalPluginCode(code);
|
||||||
const p = normalisePlugin(raw);
|
const p = normalisePlugin(raw);
|
||||||
|
|
||||||
@@ -395,7 +428,7 @@
|
|||||||
throw new Error('File does not export a valid plugin (needs an onLoad function)');
|
throw new Error('File does not export a valid plugin (needs an onLoad function)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = p.id ?? path.basename(path.dirname(filePath));
|
const id = p.id ?? inferPluginId(filePath);
|
||||||
if (knownPlugins.has(id) && registry.getRegisteredPlugins().some(r => r.id === id)) {
|
if (knownPlugins.has(id) && registry.getRegisteredPlugins().some(r => r.id === id)) {
|
||||||
throw new Error(`Plugin with id "${id}" is already loaded`);
|
throw new Error(`Plugin with id "${id}" is already loaded`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user