feat: Enhance Electron test harness with preload script and CSP configuration

This commit is contained in:
2026-04-19 03:05:29 +10:00
parent dcc17946ef
commit 6f506c59a4
5 changed files with 98 additions and 31 deletions

View File

@@ -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; }

View File

@@ -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,
}, },
}); });

View File

@@ -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
View 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');
},
});

View File

@@ -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`);
} }