mirror of
https://github.com/penpot/penpot-plugins.git
synced 2025-02-01 12:01:14 -05:00
fix(plugins-runtime): clean pending timeouts
This commit is contained in:
parent
cc6b9e5894
commit
8870dda468
7 changed files with 296 additions and 42 deletions
|
@ -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;
|
let modal: PluginModalElement | null = null;
|
||||||
|
|
||||||
const closePlugin = () => {
|
const closePlugin = () => {
|
||||||
|
@ -86,6 +90,8 @@ export function createApi(context: PenpotContext, manifest: Manifest): Penpot {
|
||||||
}
|
}
|
||||||
uiMessagesCallbacks = [];
|
uiMessagesCallbacks = [];
|
||||||
modal = null;
|
modal = null;
|
||||||
|
|
||||||
|
closed();
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkPermission = (permission: Permissions) => {
|
const checkPermission = (permission: Permissions) => {
|
||||||
|
|
|
@ -32,19 +32,23 @@ describe('Plugin api', () => {
|
||||||
removeListener: vi.fn(),
|
removeListener: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const api = createApi(mockContext as any, {
|
const api = createApi(
|
||||||
pluginId: 'test',
|
mockContext as any,
|
||||||
name: 'test',
|
{
|
||||||
code: '',
|
pluginId: 'test',
|
||||||
host: 'http://fake.com',
|
name: 'test',
|
||||||
permissions: [
|
code: '',
|
||||||
'content:read',
|
host: 'http://fake.com',
|
||||||
'content:write',
|
permissions: [
|
||||||
'library:read',
|
'content:read',
|
||||||
'library:write',
|
'content:write',
|
||||||
'user:read',
|
'library:read',
|
||||||
],
|
'library:write',
|
||||||
});
|
'user:read',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
const addEventListenerMock = vi.mocked(window.addEventListener);
|
const addEventListenerMock = vi.mocked(window.addEventListener);
|
||||||
const messageEvent = addEventListenerMock.mock.calls[0][1] as EventListener;
|
const messageEvent = addEventListenerMock.mock.calls[0][1] as EventListener;
|
||||||
|
@ -147,7 +151,8 @@ describe('Plugin api', () => {
|
||||||
name: 'test',
|
name: 'test',
|
||||||
code: '',
|
code: '',
|
||||||
permissions: [],
|
permissions: [],
|
||||||
} as any
|
} as any,
|
||||||
|
() => {}
|
||||||
);
|
);
|
||||||
|
|
||||||
it('on', () => {
|
it('on', () => {
|
||||||
|
|
166
libs/plugins-runtime/src/lib/load-plugin.spec.ts
Normal file
166
libs/plugins-runtime/src/lib/load-plugin.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -4,8 +4,8 @@ import { createApi } from './api/index.js';
|
||||||
import { loadManifest, loadManifestCode } from './parse-manifest.js';
|
import { loadManifest, loadManifestCode } from './parse-manifest.js';
|
||||||
import { Manifest } from './models/manifest.model.js';
|
import { Manifest } from './models/manifest.model.js';
|
||||||
import * as api from './api/index.js';
|
import * as api from './api/index.js';
|
||||||
|
import { ses } from './ses.js';
|
||||||
|
|
||||||
let isLockedDown = false;
|
|
||||||
let createdApis: ReturnType<typeof createApi>[] = [];
|
let createdApis: ReturnType<typeof createApi>[] = [];
|
||||||
const multiPlugin = false;
|
const multiPlugin = false;
|
||||||
|
|
||||||
|
@ -17,16 +17,16 @@ export function setContextBuilder(builder: ContextBuilder) {
|
||||||
contextBuilder = builder;
|
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 {
|
try {
|
||||||
const closeAllPlugins = () => {
|
|
||||||
createdApis.forEach((pluginApi) => {
|
|
||||||
pluginApi.closePlugin();
|
|
||||||
});
|
|
||||||
|
|
||||||
createdApis = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const context = contextBuilder && contextBuilder(manifest.pluginId);
|
const context = contextBuilder && contextBuilder(manifest.pluginId);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
@ -37,21 +37,24 @@ export const ɵloadPlugin = async function (manifest: Manifest) {
|
||||||
|
|
||||||
const code = await loadManifestCode(manifest);
|
const code = await loadManifestCode(manifest);
|
||||||
|
|
||||||
if (!isLockedDown) {
|
ses.hardenIntrinsics();
|
||||||
isLockedDown = true;
|
|
||||||
hardenIntrinsics();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createdApis && !multiPlugin) {
|
if (createdApis && !multiPlugin) {
|
||||||
closeAllPlugins();
|
closeAllPlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginApi = createApi(context, manifest);
|
const pluginApi = createApi(context, manifest, () => {
|
||||||
|
timeouts.forEach(clearTimeout);
|
||||||
|
timeouts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
createdApis.push(pluginApi);
|
createdApis.push(pluginApi);
|
||||||
|
|
||||||
const c = new Compartment({
|
const timeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||||
penpot: harden(pluginApi),
|
|
||||||
fetch: harden((...args: Parameters<typeof fetch>) => {
|
const publicPluginApi = {
|
||||||
|
penpot: ses.harden(pluginApi),
|
||||||
|
fetch: ses.harden((...args: Parameters<typeof fetch>) => {
|
||||||
const requestArgs: RequestInit = {
|
const requestArgs: RequestInit = {
|
||||||
...args[1],
|
...args[1],
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
|
@ -59,19 +62,27 @@ export const ɵloadPlugin = async function (manifest: Manifest) {
|
||||||
|
|
||||||
return fetch(args[0], requestArgs);
|
return fetch(args[0], requestArgs);
|
||||||
}),
|
}),
|
||||||
console: harden(window.console),
|
console: ses.harden(window.console),
|
||||||
Math: harden(Math),
|
Math: ses.harden(Math),
|
||||||
setTimeout: harden(
|
setTimeout: ses.harden(
|
||||||
(...[handler, timeout]: Parameters<typeof setTimeout>) => {
|
(...[handler, timeout]: Parameters<typeof setTimeout>) => {
|
||||||
return setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
handler();
|
handler();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
|
timeouts.add(timeoutId);
|
||||||
|
|
||||||
|
return timeoutId;
|
||||||
}
|
}
|
||||||
),
|
) as typeof setTimeout,
|
||||||
clearTimeout: harden((id: Parameters<typeof clearTimeout>[0]) => {
|
clearTimeout: ses.harden((id: ReturnType<typeof setTimeout>) => {
|
||||||
clearTimeout(id);
|
clearTimeout(id);
|
||||||
|
|
||||||
|
timeouts.delete(id);
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const c = ses.createCompartment(publicPluginApi);
|
||||||
|
|
||||||
c.evaluate(code);
|
c.evaluate(code);
|
||||||
|
|
||||||
|
@ -80,9 +91,23 @@ export const ɵloadPlugin = async function (manifest: Manifest) {
|
||||||
|
|
||||||
context?.removeListener(listenerId);
|
context?.removeListener(listenerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
compartment: c,
|
||||||
|
publicPluginApi,
|
||||||
|
timeouts,
|
||||||
|
context,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
closeAllPlugins();
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ɵloadPlugin = async function (manifest: Manifest) {
|
||||||
|
loadPlugin(manifest);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ɵloadPluginByUrl = async function (manifestUrl: string) {
|
export const ɵloadPluginByUrl = async function (manifestUrl: string) {
|
||||||
|
|
16
libs/plugins-runtime/src/lib/ses.ts
Normal file
16
libs/plugins-runtime/src/lib/ses.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
|
@ -7,7 +7,8 @@
|
||||||
"vitest/importMeta",
|
"vitest/importMeta",
|
||||||
"vite/client",
|
"vite/client",
|
||||||
"node",
|
"node",
|
||||||
"vitest"
|
"vitest",
|
||||||
|
"ses"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|
37
package-lock.json
generated
37
package-lock.json
generated
|
@ -14346,6 +14346,23 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"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": {
|
"node_modules/jest-circus/node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
@ -14377,6 +14394,24 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/jest-circus/node_modules/dedent": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
@ -21350,7 +21385,7 @@
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.4.5",
|
"version": "5.4.5",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|
Loading…
Add table
Reference in a new issue