diff --git a/libs/plugins-runtime/src/lib/api/index.ts b/libs/plugins-runtime/src/lib/api/index.ts index d4a1796..d916d0d 100644 --- a/libs/plugins-runtime/src/lib/api/index.ts +++ b/libs/plugins-runtime/src/lib/api/index.ts @@ -67,7 +67,11 @@ export function themeChange(theme: PenpotTheme) { }); } -export function createApi(context: PenpotContext, manifest: Manifest): Penpot { +export function createApi( + context: PenpotContext, + manifest: Manifest, + closed: () => void +): Penpot { let modal: PluginModalElement | null = null; const closePlugin = () => { @@ -86,6 +90,8 @@ export function createApi(context: PenpotContext, manifest: Manifest): Penpot { } uiMessagesCallbacks = []; modal = null; + + closed(); }; const checkPermission = (permission: Permissions) => { diff --git a/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index af843a5..3d9dbb6 100644 --- a/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -32,19 +32,23 @@ describe('Plugin api', () => { removeListener: vi.fn(), }; - const api = createApi(mockContext as any, { - pluginId: 'test', - name: 'test', - code: '', - host: 'http://fake.com', - permissions: [ - 'content:read', - 'content:write', - 'library:read', - 'library:write', - 'user:read', - ], - }); + const api = createApi( + mockContext as any, + { + pluginId: 'test', + name: 'test', + code: '', + host: 'http://fake.com', + permissions: [ + 'content:read', + 'content:write', + 'library:read', + 'library:write', + 'user:read', + ], + }, + () => {} + ); const addEventListenerMock = vi.mocked(window.addEventListener); const messageEvent = addEventListenerMock.mock.calls[0][1] as EventListener; @@ -147,7 +151,8 @@ describe('Plugin api', () => { name: 'test', code: '', permissions: [], - } as any + } as any, + () => {} ); it('on', () => { diff --git a/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/libs/plugins-runtime/src/lib/load-plugin.spec.ts new file mode 100644 index 0000000..19309c4 --- /dev/null +++ b/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, vi, expect, beforeEach, afterEach, Mock } from 'vitest'; +import { loadPlugin, setContextBuilder } from './load-plugin.js'; +import { loadManifestCode } from './parse-manifest.js'; +import { createApi, themeChange } from './api/index.js'; +import type { PenpotContext, PenpotTheme } from '@penpot/plugin-types'; +import type { Manifest } from './models/manifest.model.js'; +import { ses } from './ses.js'; + +vi.mock('./parse-manifest.js', () => ({ + loadManifestCode: vi.fn(), +})); + +vi.mock('./api/index.js', () => ({ + createApi: vi.fn(), + themeChange: vi.fn(), +})); + +vi.mock('./ses.js', () => ({ + ses: { + hardenIntrinsics: vi.fn().mockReturnValue(null), + createCompartment: vi.fn().mockReturnValue({ + evaluate: vi.fn(), + }), + harden: vi.fn().mockImplementation((obj) => obj), + }, +})); + +describe('loadPlugin', () => { + let mockContext: PenpotContext; + let manifest: Manifest = { + pluginId: 'test-plugin', + name: 'Test Plugin', + host: '', + code: '', + permissions: [ + 'content:read', + 'content:write', + 'library:read', + 'library:write', + 'user:read', + ], + }; + let mockApi: ReturnType; + let addListenerMock: ReturnType; + + beforeEach(() => { + addListenerMock = vi.fn(); + mockContext = { + addListener: addListenerMock, + removeListener: vi.fn(), + } as unknown as PenpotContext; + + mockApi = { + closePlugin: vi.fn(), + } as unknown as ReturnType; + + (createApi as Mock).mockReturnValue(mockApi); + (loadManifestCode as Mock).mockResolvedValue( + 'console.log("Plugin loaded");' + ); + setContextBuilder(() => mockContext); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should set up the context and load the manifest code', async () => { + await loadPlugin(manifest); + + expect(loadManifestCode).toHaveBeenCalledWith(manifest); + expect(createApi).toHaveBeenCalledWith( + mockContext, + manifest, + expect.any(Function) + ); + }); + + it('should handle theme change events', async () => { + await loadPlugin(manifest); + + const themeChangeListener = addListenerMock.mock.calls + .find((call) => call[0] === 'themechange') + ?.at(1); + + const mockTheme: PenpotTheme = 'dark'; + themeChangeListener(mockTheme); + + expect(themeChange).toHaveBeenCalledWith(mockTheme); + }); + + it('should close all plugins when a new plugin is loaded', async () => { + await loadPlugin(manifest); + await loadPlugin(manifest); + + expect(mockApi.closePlugin).toHaveBeenCalledTimes(1); + }); + + it('should clear timeouts on plugin close', async () => { + await loadPlugin(manifest); + + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + + const timeoutCallback = vi.fn(); + const timeoutId = setTimeout(timeoutCallback, 1000); + clearTimeout(timeoutId); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId); + expect(setTimeoutSpy).toHaveBeenCalled(); + }); + + it('should remove finish event listener on plugin finish', async () => { + await loadPlugin(manifest); + + const finishListener = addListenerMock.mock.calls + .find((call) => call[0] === 'finish') + ?.at(1); + + finishListener(); + + expect(mockContext.removeListener).toHaveBeenCalled(); + }); + + it('shoud clean setTimeout when plugin is closed', async () => { + vi.spyOn(globalThis, 'clearTimeout'); + + let closedCallback = () => {}; + + (createApi as Mock).mockImplementation((context, manifest, closed) => { + closedCallback = closed; + + return mockApi; + }); + + const plugin = await loadPlugin(manifest); + + if (!plugin) { + throw new Error('Plugin not loaded'); + } + + plugin.publicPluginApi.setTimeout(() => {}, 1000); + plugin.publicPluginApi.setTimeout(() => {}, 1000); + + expect(plugin.timeouts.size).toBe(2); + + closedCallback(); + + expect(plugin.timeouts.size).toBe(0); + expect(clearTimeout).toHaveBeenCalledTimes(2); + }); + + it('should close plugin on evaluation error', async () => { + ses.createCompartment = vi.fn().mockImplementation(() => { + return { + evaluate: vi.fn().mockImplementation(() => { + throw new Error('Error in plugin'); + }), + }; + }); + + await loadPlugin(manifest); + + expect(mockApi.closePlugin).toHaveBeenCalled(); + }); +}); diff --git a/libs/plugins-runtime/src/lib/load-plugin.ts b/libs/plugins-runtime/src/lib/load-plugin.ts index f29df2b..3ce9470 100644 --- a/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/libs/plugins-runtime/src/lib/load-plugin.ts @@ -4,8 +4,8 @@ import { createApi } from './api/index.js'; import { loadManifest, loadManifestCode } from './parse-manifest.js'; import { Manifest } from './models/manifest.model.js'; import * as api from './api/index.js'; +import { ses } from './ses.js'; -let isLockedDown = false; let createdApis: ReturnType[] = []; const multiPlugin = false; @@ -17,16 +17,16 @@ export function setContextBuilder(builder: ContextBuilder) { contextBuilder = builder; } -export const ɵloadPlugin = async function (manifest: Manifest) { +const closeAllPlugins = () => { + createdApis.forEach((pluginApi) => { + pluginApi.closePlugin(); + }); + + createdApis = []; +}; + +export const loadPlugin = async function (manifest: Manifest) { try { - const closeAllPlugins = () => { - createdApis.forEach((pluginApi) => { - pluginApi.closePlugin(); - }); - - createdApis = []; - }; - const context = contextBuilder && contextBuilder(manifest.pluginId); if (!context) { @@ -37,21 +37,24 @@ export const ɵloadPlugin = async function (manifest: Manifest) { const code = await loadManifestCode(manifest); - if (!isLockedDown) { - isLockedDown = true; - hardenIntrinsics(); - } + ses.hardenIntrinsics(); if (createdApis && !multiPlugin) { closeAllPlugins(); } - const pluginApi = createApi(context, manifest); + const pluginApi = createApi(context, manifest, () => { + timeouts.forEach(clearTimeout); + timeouts.clear(); + }); + createdApis.push(pluginApi); - const c = new Compartment({ - penpot: harden(pluginApi), - fetch: harden((...args: Parameters) => { + const timeouts = new Set>(); + + const publicPluginApi = { + penpot: ses.harden(pluginApi), + fetch: ses.harden((...args: Parameters) => { const requestArgs: RequestInit = { ...args[1], credentials: 'omit', @@ -59,19 +62,27 @@ export const ɵloadPlugin = async function (manifest: Manifest) { return fetch(args[0], requestArgs); }), - console: harden(window.console), - Math: harden(Math), - setTimeout: harden( + console: ses.harden(window.console), + Math: ses.harden(Math), + setTimeout: ses.harden( (...[handler, timeout]: Parameters) => { - return setTimeout(() => { + const timeoutId = setTimeout(() => { handler(); }, timeout); + + timeouts.add(timeoutId); + + return timeoutId; } - ), - clearTimeout: harden((id: Parameters[0]) => { + ) as typeof setTimeout, + clearTimeout: ses.harden((id: ReturnType) => { clearTimeout(id); + + timeouts.delete(id); }), - }); + }; + + const c = ses.createCompartment(publicPluginApi); c.evaluate(code); @@ -80,9 +91,23 @@ export const ɵloadPlugin = async function (manifest: Manifest) { context?.removeListener(listenerId); }); + + return { + compartment: c, + publicPluginApi, + timeouts, + context, + }; } catch (error) { + closeAllPlugins(); console.error(error); } + + return; +}; + +export const ɵloadPlugin = async function (manifest: Manifest) { + loadPlugin(manifest); }; export const ɵloadPluginByUrl = async function (manifestUrl: string) { diff --git a/libs/plugins-runtime/src/lib/ses.ts b/libs/plugins-runtime/src/lib/ses.ts new file mode 100644 index 0000000..fb63a35 --- /dev/null +++ b/libs/plugins-runtime/src/lib/ses.ts @@ -0,0 +1,16 @@ +let isLockedDown = false; + +export const ses = { + hardenIntrinsics: () => { + if (!isLockedDown) { + isLockedDown = true; + hardenIntrinsics(); + } + }, + createCompartment: (globals?: Object) => { + return new Compartment(globals); + }, + harden: (obj: Object) => { + return harden(obj); + }, +}; diff --git a/libs/plugins-runtime/tsconfig.spec.json b/libs/plugins-runtime/tsconfig.spec.json index 4f7fed6..6901d37 100644 --- a/libs/plugins-runtime/tsconfig.spec.json +++ b/libs/plugins-runtime/tsconfig.spec.json @@ -7,7 +7,8 @@ "vitest/importMeta", "vite/client", "node", - "vitest" + "vitest", + "ses" ] }, "include": [ diff --git a/package-lock.json b/package-lock.json index 62f5d91..d953741 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14346,6 +14346,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-circus/node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/jest-circus/node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -14377,6 +14394,24 @@ "dev": true, "license": "MIT" }, + "node_modules/jest-circus/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-circus/node_modules/dedent": { "version": "1.5.3", "dev": true, @@ -21350,7 +21385,7 @@ }, "node_modules/typescript": { "version": "5.4.5", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc",