penpot/plugins/libs/plugins-runtime/src/lib/load-plugin-context.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

84 lines
2.5 KiB
TypeScript

import { describe, it, vi, expect, beforeEach } from 'vitest';
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
import { createPlugin } from './create-plugin';
import { ses } from './ses.js';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./create-plugin', () => ({
createPlugin: vi.fn(),
}));
// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the
// real `ses.harden` to assert that `loadPlugin` never hardens the host
// context.
describe('loadPlugin host context boundary (regression for #11001)', () => {
let manifest: Manifest;
beforeEach(() => {
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code: '',
permissions: ['content:read'],
};
vi.mocked(createPlugin).mockResolvedValue({
plugin: {
close: vi.fn(),
sendMessage: vi.fn(),
},
} as unknown as NonNullable<Awaited<ReturnType<typeof createPlugin>>>);
});
it('does not freeze host-owned functions reachable through the context', async () => {
const hostListener = function hostListener() {
return 'host-value';
};
const nestedHostObject = {
nestedFn() {
return 'nested';
},
};
const hostContext = {
addListener: hostListener,
nested: nestedHostObject,
} as unknown as Context;
setContextBuilder(() => hostContext);
const hardenSpy = vi.spyOn(ses, 'harden');
await loadPlugin(manifest);
// The host context itself must be passed through untouched so the host
// can keep modifying its own runtime objects (e.g. on page navigation).
expect(createPlugin).toHaveBeenCalledWith(
hostContext,
manifest,
expect.any(Function),
undefined,
expect.any(AbortSignal),
);
// Host-owned functions must remain extensible: page navigation and
// runtime code may patch/augment them (e.g. assigning `toString` on a
// wrapped listener). A deep `ses.harden(context)` here would freeze
// them and turn such later assignments into
// `TypeError: Cannot assign to read only property 'toString'`.
expect(Object.isFrozen(hostListener)).toBe(false);
expect(Object.isExtensible(hostListener)).toBe(true);
expect(Object.isFrozen(nestedHostObject)).toBe(false);
expect(() => {
hostListener.toString = () => 'patched-by-host';
}).not.toThrow();
expect(hardenSpy).not.toHaveBeenCalled();
expect(getPlugins()).toHaveLength(1);
hardenSpy.mockRestore();
});
});