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:
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;
|
||||
|
||||
const closePlugin = () => {
|
||||
|
@ -86,6 +90,8 @@ export function createApi(context: PenpotContext, manifest: Manifest): Penpot {
|
|||
}
|
||||
uiMessagesCallbacks = [];
|
||||
modal = null;
|
||||
|
||||
closed();
|
||||
};
|
||||
|
||||
const checkPermission = (permission: Permissions) => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
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 { 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) {
|
||||
|
|
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",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
"vitest",
|
||||
"ses"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
|
37
package-lock.json
generated
37
package-lock.json
generated
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue