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

494 lines
16 KiB
TypeScript

import { describe, it, vi, expect, beforeAll } from 'vitest';
import 'ses';
import {
loadPlugin,
setContextBuilder,
getPlugins,
ɵunloadPlugin,
} from './load-plugin';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
// Real initialization-path regression tests for #11001.
//
// NOTE: `./create-plugin`, `./plugin-manager` and
// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
// path with the real SES implementation, mirroring the production
// initialization order from `plugins-runtime/src/index.ts`:
// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
//
// `hardenIntrinsics()` is deliberately NOT called up front: the first test
// must run in the production window where only `repairIntrinsics` has run.
// Tests run in declaration order; the later tests build on the locked-down
// state the first real `loadPlugin` leaves behind (via `createSandbox`).
//
// Note on SES isolation: this suite depends on Vitest's default
// file-level isolation. Each test file runs in a separate worker
// process, so SES intrinsics frozen here do not leak into other
// spec files.
const REPAIR_OPTIONS = {
evalTaming: 'unsafeEval',
stackFiltering: 'verbose',
errorTaming: 'unsafe',
consoleTaming: 'unsafe',
errorTrapping: 'none',
unhandledRejectionTrapping: 'none',
};
function makeManifest(
code: string,
permissions: Manifest['permissions'],
): Manifest {
return {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code,
permissions,
};
}
function makeHostFixture() {
const listenerTypes: string[] = [];
const listeners = new Map<
symbol,
{ type: string; callback: (...args: unknown[]) => unknown }
>();
const shapes: object[] = [];
// Inline code (empty host + non-URL code) resolves without network, so no
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
const createRectangle = vi.fn(() => {
const shape = { type: 'rectangle-marker' };
shapes.push(shape);
return shape;
});
const selection: object[] = [{ id: 'shape-1' }];
const context = {
addListener: (type: string, callback: (...args: unknown[]) => unknown) => {
const id = Symbol(type);
listeners.set(id, { type, callback });
listenerTypes.push(type);
return id;
},
removeListener: (id: symbol) => {
listeners.delete(id);
},
theme: 'dark',
management: { workspace: { status: 'ready' } },
createRectangle,
selection,
// Host-only member: present on the raw context but NOT part of the
// public penpot API. Plugin code must never see it (see B-2 below).
__internalSecret: 'host-internal',
} as unknown as Context;
return {
context,
listeners,
listenerTypes,
createRectangle,
selection,
shapes,
};
}
function lastCompartmentGlobalThis(): Record<string, unknown> {
const plugins = getPlugins();
const last = plugins[plugins.length - 1] as unknown as {
compartment: { compartment: { globalThis: Record<string, unknown> } };
};
return last.compartment.compartment.globalThis;
}
describe('loadPlugin real initialization path (regression for #11001)', () => {
beforeAll(() => {
// Production module-load step only: repairs intrinsics WITHOUT
// installing override taming, exactly like `index.ts` at import time.
(
globalThis as unknown as { repairIntrinsics(opts: object): void }
).repairIntrinsics({ ...REPAIR_OPTIONS });
});
it('loads through the real path and keeps host function augmentation working', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.on("finish", function () {});', ['content:read']),
);
// The plugin code really ran inside the sandbox: the manager registers
// `themechange` + `finish` + `logout`, and the plugin code adds its own `finish`
// listener through the public API.
expect(fixture.listenerTypes).toEqual([
'themechange',
'finish',
'logout',
'finish',
]);
expect(getPlugins()).toHaveLength(1);
// The user-facing behavior from #11001: host-side augmentation of a
// fresh function (e.g. assigning `toString` during page navigation)
// succeeds after a real plugin load.
const freshWrapper = function freshWrapper() {
return 'navigation-wrapper';
};
expect(() => {
freshWrapper.toString = () => 'patched-by-runtime';
}).not.toThrow();
expect(fixture.listenerTypes.length).toBe(4);
});
it('denies the write API without permission and leaves the host untouched', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await expect(
loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
).rejects.toThrow(/content:write/);
expect(fixture.createRectangle).not.toHaveBeenCalled();
});
it('allows the same write API with permission', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.createRectangle();', [
'content:read',
'content:write',
]),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
});
it('does not expose raw host-only context members to plugin code', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
'content:read',
]),
);
// The public `penpot` object is a boundary proxy over a curated API, not
// the raw host context, so host-only members are invisible inside.
expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
});
it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest(
'penpot.createRectangle(); ' +
'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
['content:read', 'content:write'],
),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
});
it.each(['before', 'after'])(
'discards a cancelled global load that completes %s its replacement',
async (order) => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', ['content:read', 'content:write']),
host: 'https://plugins.test/',
scope: 'global' as const,
};
ɵunloadPlugin(manifest.pluginId);
let finishFetch!: (response: object) => void;
const response = new Promise((resolve) => {
finishFetch = resolve;
});
const fetch = vi.fn().mockReturnValue(response);
vi.stubGlobal('fetch', fetch);
try {
const cancelled = loadPlugin(manifest);
await loadPlugin(manifest);
expect(fetch).toHaveBeenCalledOnce();
ɵunloadPlugin(manifest.pluginId);
const replacement = () =>
loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "replacement";',
});
if (order === 'after') await replacement();
finishFetch({
ok: true,
text: async () => 'penpot.createRectangle();',
});
await cancelled;
expect(fixture.shapes).toEqual([]);
if (order === 'before') {
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
await replacement();
}
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
expect(fixture.listeners.size).toBe(3);
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
},
);
it('cancels a workspace load when another load replaces the same plugin', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', ['content:read', 'content:write']),
host: 'https://plugins.test/',
};
let finishFetch!: (response: object) => void;
vi.stubGlobal(
'fetch',
() =>
new Promise((resolve) => {
finishFetch = resolve;
}),
);
try {
const cancelled = loadPlugin(manifest);
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "replacement";',
});
finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' });
await cancelled;
expect(fixture.shapes).toEqual([]);
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
} finally {
for (const plugin of [...getPlugins()]) plugin.plugin.close();
vi.unstubAllGlobals();
}
});
it('ignores a fetch failure after a load has been cancelled', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', []),
host: 'https://plugins.test/',
scope: 'global' as const,
};
let failFetch!: (error: Error) => void;
vi.stubGlobal(
'fetch',
() =>
new Promise((_resolve, reject) => {
failFetch = reject;
}),
);
try {
const cancelled = loadPlugin(manifest);
ɵunloadPlugin(manifest.pluginId);
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "replacement";',
});
failFetch(new Error('Cancelled fetch failed'));
await cancelled;
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('replacement');
expect(fixture.listeners.size).toBe(3);
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
});
it('allows retrying a global plugin after a failed load', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('throw new Error("load failed");', []),
scope: 'global' as const,
};
await expect(loadPlugin(manifest)).rejects.toThrow('load failed');
expect(getPlugins()).toHaveLength(0);
await loadPlugin({ ...manifest, code: 'globalThis.marker = "retry";' });
expect(lastCompartmentGlobalThis()['marker']).toBe('retry');
ɵunloadPlugin(manifest.pluginId);
expect(fixture.listeners.size).toBe(0);
});
it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => {
const fixture = makeHostFixture();
let workspace = {
status: 'none',
fileId: null,
fileName: null,
teamId: null,
};
const listeners = new Map<
symbol,
{ type: string; callback: (value?: unknown) => void }
>();
const context = {
...fixture.context,
management: {
get workspace() {
return workspace;
},
openFile: async () => {},
},
addListener(type: string, callback: (value?: unknown) => void) {
const id = Symbol();
listeners.set(id, { type, callback });
return id;
},
removeListener(id: symbol) {
listeners.delete(id);
},
} as unknown as Context;
setContextBuilder(() => context);
const manifest = {
...makeManifest(
'globalThis.savedPenpot = penpot; globalThis.probe = penpotMgmt.workspace.status;',
['content:read', 'content:write'],
),
pluginId: 'global-plugin',
scope: 'global' as const,
};
await loadPlugin(manifest);
const globalPlugin = getPlugins().find(
(plugin) => plugin.manifest.pluginId === 'global-plugin',
)!;
const globals = globalPlugin.compartment.compartment.globalThis;
expect(globals['probe']).toBe('none');
expect(() =>
globalPlugin.compartment.compartment.evaluate('penpot.createRectangle()'),
).toThrow(/No workspace/);
for (const listener of [...listeners.values()]) {
if (listener.type === 'finish') listener.callback();
}
await loadPlugin(makeManifest('globalThis.workspacePlugin = true;', []));
expect(getPlugins()).toContain(globalPlugin);
await loadPlugin(manifest);
expect(
getPlugins().filter(
(plugin) => plugin.manifest.pluginId === 'global-plugin',
),
).toHaveLength(1);
workspace = { ...workspace, status: 'ready' };
globalPlugin.compartment.compartment.evaluate(
'penpot.createRectangle(); globalThis.sameFacade = savedPenpot === penpot;',
);
expect(globals['sameFacade']).toBe(true);
expect(fixture.createRectangle).toHaveBeenCalledOnce();
await expect(
loadPlugin(makeManifest('throw new Error("failure");', [])),
).rejects.toThrow('failure');
expect(getPlugins()).toContain(globalPlugin);
for (const listener of [...listeners.values()]) {
if (listener.type === 'logout') listener.callback();
}
expect(getPlugins()).toHaveLength(0);
expect(globals['penpotMgmt']).toBeUndefined();
expect(listeners.size).toBe(0);
});
it('cancels a global load at logout before it can execute in another session', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', ['content:read', 'content:write']),
pluginId: 'logout-plugin',
host: 'https://plugins.test/',
scope: 'global' as const,
};
let finishFetch!: (response: object) => void;
vi.stubGlobal(
'fetch',
() =>
new Promise((resolve) => {
finishFetch = resolve;
}),
);
try {
const cancelled = loadPlugin(manifest);
for (const listener of [...fixture.listeners.values()]) {
if (listener.type === 'logout') listener.callback();
}
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "new-session";',
});
finishFetch({ ok: true, text: async () => 'penpot.createRectangle();' });
await cancelled;
expect(fixture.shapes).toEqual([]);
expect(getPlugins()).toHaveLength(1);
expect(lastCompartmentGlobalThis()['marker']).toBe('new-session');
expect(fixture.listeners.size).toBe(3);
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
});
it('cleans up a failed fetch and allows loading the plugin again', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('plugin.js', []),
pluginId: 'failed-fetch-plugin',
host: 'https://plugins.test/',
scope: 'global' as const,
};
vi.stubGlobal('fetch', () => Promise.reject(new Error('Fetch failed')));
try {
await expect(loadPlugin(manifest)).rejects.toThrow('Fetch failed');
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
await loadPlugin({
...manifest,
host: '',
code: 'globalThis.marker = "retry";',
});
expect(lastCompartmentGlobalThis()['marker']).toBe('retry');
} finally {
ɵunloadPlugin(manifest.pluginId);
vi.unstubAllGlobals();
}
});
it('does not register a global plugin that closes during startup and permits reopening', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
const manifest = {
...makeManifest('penpot.closePlugin();', [
'content:read',
'content:write',
]),
pluginId: 'self-closing-plugin',
scope: 'global' as const,
};
try {
await loadPlugin(manifest);
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
await loadPlugin({ ...manifest, code: 'penpot.createRectangle();' });
expect(fixture.shapes).toEqual([{ type: 'rectangle-marker' }]);
expect(getPlugins()).toHaveLength(1);
} finally {
ɵunloadPlugin(manifest.pluginId);
}
expect(getPlugins()).toHaveLength(0);
expect(fixture.listeners.size).toBe(0);
});
});