penpot/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts
Andrey Antukh f7e1bcf87f
🐛 Handle plugin errors gracefully without crashing the UI (#8810)
* 🐛 Handle plugin errors gracefully without crashing the UI

Plugin errors (like 'Set is not a constructor') were propagating to the
global error handler and showing the exception page. This fix:

- Uses a WeakMap to track plugin errors (works in SES hardened environment)
- Wraps setTimeout/setInterval handlers to mark errors and re-throw them
- Frontend global handler checks isPluginError and logs to console

Plugin errors are now logged to console with 'Plugin Error' prefix but
don't crash the main application or show the exception page.

Signed-off-by: AI Agent <agent@penpot.app>

*  Improved handling of plugin errors on initialization

*  Fix test and linter

---------

Signed-off-by: AI Agent <agent@penpot.app>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-04-01 11:37:27 +02:00

129 lines
3.7 KiB
TypeScript

import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest';
import { createPlugin } from './create-plugin';
import { createPluginManager } from './plugin-manager.js';
import { createSandbox } from './create-sandbox.js';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./plugin-manager.js', () => ({
createPluginManager: vi.fn(),
}));
vi.mock('./create-sandbox.js', () => ({
createSandbox: vi.fn(),
markPluginError: vi.fn(),
}));
describe('createPlugin', () => {
let mockContext: Context;
let manifest: Manifest;
let onCloseCallback: () => void;
let mockPluginManager: Awaited<ReturnType<typeof createPluginManager>>;
let mockSandbox: ReturnType<typeof createSandbox>;
beforeEach(() => {
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: 'https://example.com',
code: '',
permissions: [
'content:read',
'content:write',
'library:read',
'library:write',
'user:read',
'comment:read',
'comment:write',
'allow:downloads',
'allow:localstorage',
],
};
mockPluginManager = {
close: vi.fn(),
openModal: vi.fn(),
getModal: vi.fn(),
registerListener: vi.fn(),
registerMessageCallback: vi.fn(),
sendMessage: vi.fn(),
destroyListener: vi.fn(),
context: mockContext,
manifest,
timeouts: new Set(),
code: 'console.log("Plugin running");',
} as unknown as Awaited<ReturnType<typeof createPluginManager>>;
mockSandbox = {
evaluate: vi.fn(),
cleanGlobalThis: vi.fn(),
compartment: {},
} as unknown as ReturnType<typeof createSandbox>;
mockContext = {
addListener: vi.fn(),
removeListener: vi.fn(),
} as unknown as Context;
onCloseCallback = vi.fn();
vi.mocked(createPluginManager).mockResolvedValue(mockPluginManager);
vi.mocked(createSandbox).mockReturnValue(mockSandbox);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should create the plugin manager and sandbox, then evaluate the plugin code', async () => {
const result = await createPlugin(mockContext, manifest, onCloseCallback);
expect(createPluginManager).toHaveBeenCalledWith(
mockContext,
manifest,
expect.any(Function),
expect.any(Function),
);
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined);
expect(mockSandbox.evaluate).toHaveBeenCalled();
expect(result).toEqual({
plugin: mockPluginManager,
compartment: mockSandbox,
manifest,
});
});
it('should clean globalThis and call onCloseCallback when plugin is closed', async () => {
await createPlugin(mockContext, manifest, onCloseCallback);
const onClose = vi.mocked(createPluginManager).mock.calls[0][2];
onClose();
expect(mockSandbox.cleanGlobalThis).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
it('should re-evaluate the plugin code when the modal is reloaded', async () => {
await createPlugin(mockContext, manifest, onCloseCallback);
const onReloadModal = vi.mocked(createPluginManager).mock.calls[0][3];
onReloadModal('');
expect(mockSandbox.evaluate).toHaveBeenCalled();
});
it('should call plugin.close when there is an exception during sandbox evaluation', async () => {
vi.mocked(mockSandbox.evaluate).mockImplementation(() => {
throw new Error('Evaluation error');
});
try {
await createPlugin(mockContext, manifest, onCloseCallback);
} catch (err) {
expect.assert(err);
}
expect(mockPluginManager.close).toHaveBeenCalled();
});
});