penpot/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts
alonso.torres cf1fbb4d7c 🐛 Close plugins whose load is cancelled before they run
Unloading or replacing a plugin while its code was still being
fetched could leave a late instance running, or block reopening a
plugin that closed during startup.

Each load now carries an AbortController. Unloading, a newer load
of the same plugin or logout aborts it, and the plugin manager
closes itself on abort, also while it fetches its code.
createPlugin returns nothing for a plugin closed before its
sandbox exists, and failed loads clear their pending state so the
plugin can be loaded again.
2026-10-07 14:58:32 +02:00

130 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),
undefined,
);
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();
});
});