0
Fork 0
mirror of https://github.com/penpot/penpot-plugins.git synced 2025-01-21 06:02:34 -05:00

fix(plugins-runtime): clean pending timeouts

This commit is contained in:
Juanfran 2024-08-01 12:42:04 +02:00
parent cc6b9e5894
commit 8870dda468
7 changed files with 296 additions and 42 deletions

View file

@ -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) => {

View file

@ -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', () => {

View file

@ -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<typeof createApi>;
let addListenerMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
addListenerMock = vi.fn();
mockContext = {
addListener: addListenerMock,
removeListener: vi.fn(),
} as unknown as PenpotContext;
mockApi = {
closePlugin: vi.fn(),
} as unknown as ReturnType<typeof createApi>;
(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();
});
});

View file

@ -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<typeof createApi>[] = [];
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<typeof fetch>) => {
const timeouts = new Set<ReturnType<typeof setTimeout>>();
const publicPluginApi = {
penpot: ses.harden(pluginApi),
fetch: ses.harden((...args: Parameters<typeof fetch>) => {
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<typeof setTimeout>) => {
return setTimeout(() => {
const timeoutId = setTimeout(() => {
handler();
}, timeout);
timeouts.add(timeoutId);
return timeoutId;
}
),
clearTimeout: harden((id: Parameters<typeof clearTimeout>[0]) => {
) as typeof setTimeout,
clearTimeout: ses.harden((id: ReturnType<typeof setTimeout>) => {
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) {

View file

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

View file

@ -7,7 +7,8 @@
"vitest/importMeta",
"vite/client",
"node",
"vitest"
"vitest",
"ses"
]
},
"include": [

37
package-lock.json generated
View file

@ -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",