From 5049f3b415356a23f079c1c21af822cb256c1608 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 25 Jun 2026 09:59:28 -0400 Subject: [PATCH 1/2] Add plugin auto-discovery mechanism --- src/global.d.ts | 2 + src/plug.ts | 242 +++++++++++++++++++++++++++++++------------- src/plugin.ts | 12 +++ test/plug.test.ts | 253 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 439 insertions(+), 70 deletions(-) diff --git a/src/global.d.ts b/src/global.d.ts index 0b795ff4..9be734c6 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,4 +1,5 @@ import type {Plug} from './plug'; +import type {PluginFactory} from './plugin'; declare global { type CroctCallback = (instance: Plug) => void; @@ -6,5 +7,6 @@ declare global { interface Window { croct?: Plug; onCroctLoad: CroctCallback | undefined; + croctPlugins?: Record; } } diff --git a/src/plug.ts b/src/plug.ts index 20ec7656..d1b7c7db 100644 --- a/src/plug.ts +++ b/src/plug.ts @@ -86,6 +86,15 @@ export interface Plug { const PLUGIN_NAMESPACE = 'Plugin'; +/** + * The state of a single plug session needed to enable plugins. + */ +type PluginContext = { + sdk: SdkFacade, + appId: string, + logger: Logger, +}; + function detectAppId(): string | null { const script = window.document.querySelector(`script[src^='${CDN_URL}']`); @@ -186,6 +195,15 @@ export class GlobalPlug implements Plug { ); } + const configurations = plugins ?? {}; + const context: PluginContext = { + sdk: sdk, + appId: appId, + logger: logger, + }; + + this.watchPluginRegistry(context, configurations); + const pending: Array> = []; const defaultEnabledPlugins = Object.fromEntries( @@ -193,98 +211,182 @@ export class GlobalPlug implements Plug { .map(name => [name, true]), ); - for (const [name, options] of Object.entries({...defaultEnabledPlugins, ...plugins})) { - logger.debug(`Initializing plugin "${name}"...`); + for (const [name, options] of Object.entries({...defaultEnabledPlugins, ...configurations})) { + const promise = this.enablePlugin(name, options, context); - const factory = this.pluginFactories[name]; - - if (factory === undefined) { - logger.error(`Plugin "${name}" is not registered.`); - - continue; + if (promise instanceof Promise) { + pending.push(promise); } + } - if (typeof options !== 'boolean' && (options === null || typeof options !== 'object')) { - logger.error( - `Invalid options for plugin "${name}", ` - + `expected either boolean or object but got ${describe(options)}`, - ); + Promise.all(pending) + .then(() => { + this.initialize(); - continue; - } + logger.debug('Initialization complete'); + }); + } - if (options === false) { - logger.warn(`Plugin "${name}" is declared but not enabled`); + /** + * Registers plugins declared on the `window.croctPlugins` global registry. + * + * Plugins already present are registered eagerly so they are enabled as part + * of the regular initialization. The registry is then replaced with a proxy + * that registers and enables any plugin declared after the plug is loaded, + * making the registration order irrelevant. + * + * The proxy traps the captured session: once the plug is unplugged or plugged + * again, the `this.instance === context.sdk` check turns it inert, so no + * registry state has to be reset on the instance. + */ + private watchPluginRegistry(context: PluginContext, configurations: PluginConfigurations): void { + const {sdk, logger} = context; + + // Spread into a fresh object so a proxy installed by a previous session is + // discarded rather than re-wrapped, preventing proxies from nesting. + const registry = {...window.croctPlugins}; + + for (const [name, factory] of Object.entries(registry)) { + this.registerExternalPlugin(name, factory, logger); + } - continue; - } + window.croctPlugins = new Proxy(registry, { + set: (target, property, factory): boolean => { + const result = Reflect.set(target, property, factory); - const args: PluginArguments = { - options: options === true ? {} : options, - sdk: { - version: VERSION, - appId: appId, - plug: this, - tracker: sdk.tracker, - evaluator: sdk.evaluator, - user: sdk.user, - session: sdk.session, - tab: sdk.context.getTab(), - userTokenStore: { - getToken: sdk.getToken.bind(sdk), - setToken: sdk.setToken.bind(sdk), - }, - previewTokenStore: sdk.previewTokenStore, - cidAssigner: sdk.cidAssigner, - eventManager: sdk.eventManager, - getLogger: (...namespace: string[]): Logger => sdk.getLogger(PLUGIN_NAMESPACE, name, ...namespace), - getTabStorage: (...namespace: string[]): Storage => ( - sdk.getTabStorage(PLUGIN_NAMESPACE, name, ...namespace) - ), - getBrowserStorage: (...namespace: string[]): Storage => ( - sdk.getBrowserStorage(PLUGIN_NAMESPACE, name, ...namespace) - ), - }, - }; + if (result && this.instance === sdk && typeof property === 'string') { + if (this.registerExternalPlugin(property, factory, logger)) { + void this.enablePlugin(property, configurations[property] ?? true, context); + } + } + + return result; + }, + }); + } - let plugin; + /** + * Registers a plugin factory declared on the global registry. + * + * Unlike {@link extend}, conflicts are tolerated so a single malformed or + * duplicated declaration cannot prevent the plug from initializing. A factory + * already registered under the same name is silently ignored, allowing the + * registry to be safely re-scanned across re-plugs. + * + * @returns Whether the factory was newly registered. + */ + private registerExternalPlugin(name: string, factory: unknown, logger: Logger): boolean { + if (typeof factory !== 'function') { + logger.error(`The plugin "${name}" declared globally is not a valid factory, ignoring it.`); + + return false; + } - try { - plugin = factory(args); - } catch (error) { - logger.error(`Failed to initialize plugin "${name}": ${formatCause(error)}`); + const registered = this.pluginFactories[name]; - continue; + if (registered !== undefined) { + if (registered !== factory) { + logger.warn(`Plugin "${name}" is already registered, ignoring global registration.`); } - logger.debug(`Plugin "${name}" initialized`); + return false; + } - if (typeof plugin !== 'object') { - continue; - } + this.pluginFactories[name] = factory as PluginFactory; - this.plugins[name] = plugin; + return true; + } - const promise = plugin.enable(); + /** + * Instantiates and enables a single plugin. + * + * @returns A promise that resolves once the plugin is enabled, or nothing if + * the plugin is synchronous, disabled, or could not be initialized. + */ + private enablePlugin(name: string, options: unknown, context: PluginContext): Promise | void { + const {sdk, appId, logger} = context; - if (!(promise instanceof Promise)) { - logger.debug(`Plugin "${name}" enabled`); + logger.debug(`Initializing plugin "${name}"...`); - continue; - } + const factory = this.pluginFactories[name]; - pending.push( - promise.then(() => logger.debug(`Plugin "${name}" enabled`)) - .catch(error => logger.error(`Failed to enable plugin "${name}": ${formatCause(error)}`)), + if (factory === undefined) { + logger.error(`Plugin "${name}" is not registered.`); + + return; + } + + if (typeof options !== 'boolean' && (options === null || typeof options !== 'object')) { + logger.error( + `Invalid options for plugin "${name}", ` + + `expected either boolean or object but got ${describe(options)}`, ); + + return; } - Promise.all(pending) - .then(() => { - this.initialize(); + if (options === false) { + logger.warn(`Plugin "${name}" is declared but not enabled`); - logger.debug('Initialization complete'); - }); + return; + } + + const args: PluginArguments = { + options: options === true ? {} : options, + sdk: { + version: VERSION, + appId: appId, + plug: this, + tracker: sdk.tracker, + evaluator: sdk.evaluator, + user: sdk.user, + session: sdk.session, + tab: sdk.context.getTab(), + userTokenStore: { + getToken: sdk.getToken.bind(sdk), + setToken: sdk.setToken.bind(sdk), + }, + previewTokenStore: sdk.previewTokenStore, + cidAssigner: sdk.cidAssigner, + eventManager: sdk.eventManager, + getLogger: (...namespace: string[]): Logger => sdk.getLogger(PLUGIN_NAMESPACE, name, ...namespace), + getTabStorage: (...namespace: string[]): Storage => ( + sdk.getTabStorage(PLUGIN_NAMESPACE, name, ...namespace) + ), + getBrowserStorage: (...namespace: string[]): Storage => ( + sdk.getBrowserStorage(PLUGIN_NAMESPACE, name, ...namespace) + ), + }, + }; + + let plugin; + + try { + plugin = factory(args); + } catch (error) { + logger.error(`Failed to initialize plugin "${name}": ${formatCause(error)}`); + + return; + } + + logger.debug(`Plugin "${name}" initialized`); + + if (typeof plugin !== 'object') { + return; + } + + this.plugins[name] = plugin; + + const promise = plugin.enable(); + + if (!(promise instanceof Promise)) { + logger.debug(`Plugin "${name}" enabled`); + + return; + } + + return promise.then(() => logger.debug(`Plugin "${name}" enabled`)) + .catch(error => logger.error(`Failed to enable plugin "${name}": ${formatCause(error)}`)); } public get initialized(): boolean { diff --git a/src/plugin.ts b/src/plugin.ts index 1c0f8294..ca15fce8 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -25,6 +25,18 @@ export interface PluginSdk { getBrowserStorage(...namespace: string[]): Storage; } +export namespace PluginSdk { + export function register(name: string, factory: PluginFactory): void { + if (typeof window !== 'undefined') { + if (window.croctPlugins === undefined) { + window.croctPlugins = {}; + } + + window.croctPlugins[name] = factory; + } + } +} + export interface PluginArguments { options: T; sdk: PluginSdk; diff --git a/test/plug.test.ts b/test/plug.test.ts index 43c2e895..48bb204c 100644 --- a/test/plug.test.ts +++ b/test/plug.test.ts @@ -5,6 +5,7 @@ import type {JsonObject} from '@croct/json'; import {loadSlotContent} from '@croct/content'; import type {Logger} from '../src/sdk'; import type {Plugin, PluginFactory} from '../src/plugin'; +import {PluginSdk} from '../src/plugin'; import type {FetchResponse} from '../src/plug'; import {GlobalPlug} from '../src/plug'; import {CDN_URL} from '../src/constants'; @@ -47,6 +48,8 @@ describe('The Croct plug', () => { jest.restoreAllMocks(); process.env = ENV_VARS; + delete window.croctPlugins; + await croct.unplug(); }); @@ -388,6 +391,256 @@ describe('The Croct plug', () => { expect(getBrowserStorage).toHaveBeenCalledWith('Plugin', 'foo', 'namespace'); }); + it('should auto-discover and enable plugins declared on window.croctPlugins', () => { + const fooFactory: PluginFactory = jest.fn(); + + window.croctPlugins = { + foo: fooFactory, + }; + + croct.plug({appId: APP_ID}); + + expect(fooFactory).toHaveBeenCalledWith(expect.objectContaining({options: {}})); + }); + + it('should enable a plugin registered through PluginSdk.register', () => { + const fooFactory: PluginFactory = jest.fn(); + + PluginSdk.register('foo', fooFactory); + + expect(window.croctPlugins).toEqual({foo: fooFactory}); + + croct.plug({appId: APP_ID}); + + expect(fooFactory).toHaveBeenCalledWith(expect.objectContaining({options: {}})); + }); + + it('should pass the configured options to an auto-discovered plugin', () => { + const fooFactory: PluginFactory = jest.fn(); + + window.croctPlugins = { + foo: fooFactory, + }; + + croct.plug({ + appId: APP_ID, + plugins: { + foo: {flag: true}, + }, + }); + + expect(fooFactory).toHaveBeenCalledWith(expect.objectContaining({options: {flag: true}})); + }); + + it('should not enable an auto-discovered plugin disabled through the configuration', () => { + const fooFactory: PluginFactory = jest.fn(); + + window.croctPlugins = { + foo: fooFactory, + }; + + croct.plug({ + appId: APP_ID, + plugins: { + foo: false, + }, + }); + + expect(fooFactory).not.toHaveBeenCalled(); + }); + + it('should ignore an auto-discovered plugin conflicting with a registered one', () => { + const registeredFactory: PluginFactory = jest.fn(); + const discoveredFactory: PluginFactory = jest.fn(); + + croct.extend('foo', registeredFactory); + + window.croctPlugins = { + foo: discoveredFactory, + }; + + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + croct.plug({ + appId: APP_ID, + logger: logger, + }); + + expect(registeredFactory).toHaveBeenCalled(); + expect(discoveredFactory).not.toHaveBeenCalled(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Plugin "foo" is already registered, ignoring global registration.'), + ); + }); + + it('should ignore a globally declared plugin that is not a valid factory', () => { + window.croctPlugins = { + foo: 'not a factory' as unknown as PluginFactory, + }; + + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + croct.plug({ + appId: APP_ID, + logger: logger, + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('The plugin "foo" declared globally is not a valid factory, ignoring it.'), + ); + }); + + it('should not enable a plugin registered after the plug is unplugged', async () => { + const fooFactory: PluginFactory = jest.fn(); + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + await croct.unplug(); + + PluginSdk.register('foo', fooFactory); + + expect(fooFactory).not.toHaveBeenCalled(); + }); + + it('should re-discover and re-enable a globally declared plugin after re-plugging', async () => { + const fooFactory: PluginFactory = jest.fn(); + + window.croctPlugins = { + foo: fooFactory, + }; + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + expect(fooFactory).toHaveBeenCalledTimes(1); + + await croct.unplug(); + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + expect(fooFactory).toHaveBeenCalledTimes(2); + }); + + it('should keep discovering plugins across repeated plug/unplug cycles', async () => { + const fooFactory: PluginFactory = jest.fn(); + + window.croctPlugins = { + foo: fooFactory, + }; + + for (let cycle = 1; cycle <= 3; cycle++) { + croct.plug({appId: APP_ID}); + + await croct.plugged; + + expect(fooFactory).toHaveBeenCalledTimes(cycle); + + await croct.unplug(); + } + }); + + it('should enable on the next plug a plugin registered while unplugged', async () => { + const fooFactory: PluginFactory = jest.fn(); + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + await croct.unplug(); + + // Registered while unplugged: ignored now, but picked up on the next plug. + PluginSdk.register('foo', fooFactory); + + expect(fooFactory).not.toHaveBeenCalled(); + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + expect(fooFactory).toHaveBeenCalledTimes(1); + }); + + it('should discover and enable a plugin registered after the plug is loaded', async () => { + const fooFactory: PluginFactory = jest.fn(); + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + PluginSdk.register('foo', fooFactory); + + expect(fooFactory).toHaveBeenCalledWith(expect.objectContaining({options: {}})); + }); + + it('should apply the configured options to a plugin registered after the plug is loaded', async () => { + const fooFactory: PluginFactory = jest.fn(); + + croct.plug({ + appId: APP_ID, + plugins: { + foo: {flag: true}, + }, + }); + + await croct.plugged; + + PluginSdk.register('foo', fooFactory); + + expect(fooFactory).toHaveBeenCalledWith(expect.objectContaining({options: {flag: true}})); + }); + + it('should not enable a plugin registered after the plug if disabled in the configuration', async () => { + const fooFactory: PluginFactory = jest.fn(); + + croct.plug({ + appId: APP_ID, + plugins: { + foo: false, + }, + }); + + await croct.plugged; + + PluginSdk.register('foo', fooFactory); + + expect(fooFactory).not.toHaveBeenCalled(); + }); + + it('should not re-enable a plugin already enabled when registered again after load', async () => { + const fooFactory: PluginFactory = jest.fn(); + + window.croctPlugins = { + foo: fooFactory, + }; + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + expect(fooFactory).toHaveBeenCalledTimes(1); + + // Re-registering the same factory must not enable the plugin twice. + PluginSdk.register('foo', fooFactory); + + expect(fooFactory).toHaveBeenCalledTimes(1); + }); + it('should handle failures enabling plugins', async () => { const plugin: Plugin = { enable: jest.fn().mockReturnValue(Promise.reject(new Error('Failure'))), From 0f75973192b139e4e27f3d506d354e5cb541fe18 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 25 Jun 2026 11:04:38 -0400 Subject: [PATCH 2/2] Apply PR review --- src/plug.ts | 45 ++++++++++++++-- src/plugin.ts | 22 ++++++-- test/plug.test.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/plug.ts b/src/plug.ts index d1b7c7db..ba3bcfa0 100644 --- a/src/plug.ts +++ b/src/plug.ts @@ -19,6 +19,7 @@ import type {FetchOptions as BaseFetchOptions} from '@croct/sdk/facade/contentFe import type {FetchResponseOptions, FetchResponse as BaseFetchResponse} from '@croct/sdk/contentFetcher'; import {loadSlotContent} from '@croct/content'; import type {Plugin, PluginArguments, PluginFactory} from './plugin'; +import {isReservedPluginName} from './plugin'; import {CDN_URL} from './constants'; import {factory as previewPluginFactory} from './plugins/preview'; import {factory as autoTrackingPluginFactory} from './plugins/autoTracking'; @@ -86,6 +87,12 @@ export interface Plug { const PLUGIN_NAMESPACE = 'Plugin'; +const objectHasOwnProperty = Object.prototype.hasOwnProperty; + +function hasOwn(target: object, property: PropertyKey): boolean { + return objectHasOwnProperty.call(target, property); +} + /** * The state of a single plug session needed to enable plugins. */ @@ -129,7 +136,11 @@ export class GlobalPlug implements Plug { } public extend(name: string, plugin: PluginFactory): void { - if (this.pluginFactories[name] !== undefined) { + if (isReservedPluginName(name)) { + throw new Error(`The plugin name "${name}" is reserved and cannot be used.`); + } + + if (hasOwn(this.pluginFactories, name)) { throw new Error(`Another plugin is already registered with name "${name}".`); } @@ -256,12 +267,26 @@ export class GlobalPlug implements Plug { if (result && this.instance === sdk && typeof property === 'string') { if (this.registerExternalPlugin(property, factory, logger)) { - void this.enablePlugin(property, configurations[property] ?? true, context); + // Read the options as an own property only, so inherited keys + // such as `toString` are treated as "not configured". + const options = hasOwn(configurations, property) ? configurations[property] : true; + + void this.enablePlugin(property, options, context); } } return result; }, + deleteProperty: (target, property): boolean => { + if (this.instance === sdk && typeof property === 'string' && hasOwn(this.pluginFactories, property)) { + logger.error( + `Plugin "${property}" cannot be unregistered; ` + + 'it will remain registered until the page is reloaded.', + ); + } + + return Reflect.deleteProperty(target, property); + }, }); } @@ -276,13 +301,19 @@ export class GlobalPlug implements Plug { * @returns Whether the factory was newly registered. */ private registerExternalPlugin(name: string, factory: unknown, logger: Logger): boolean { + if (isReservedPluginName(name)) { + logger.error(`The plugin name "${name}" is reserved and cannot be used, ignoring it.`); + + return false; + } + if (typeof factory !== 'function') { logger.error(`The plugin "${name}" declared globally is not a valid factory, ignoring it.`); return false; } - const registered = this.pluginFactories[name]; + const registered = hasOwn(this.pluginFactories, name) ? this.pluginFactories[name] : undefined; if (registered !== undefined) { if (registered !== factory) { @@ -306,9 +337,15 @@ export class GlobalPlug implements Plug { private enablePlugin(name: string, options: unknown, context: PluginContext): Promise | void { const {sdk, appId, logger} = context; + if (isReservedPluginName(name)) { + logger.error(`The plugin name "${name}" is reserved and cannot be used, ignoring it.`); + + return; + } + logger.debug(`Initializing plugin "${name}"...`); - const factory = this.pluginFactories[name]; + const factory = hasOwn(this.pluginFactories, name) ? this.pluginFactories[name] : undefined; if (factory === undefined) { logger.error(`Plugin "${name}" is not registered.`); diff --git a/src/plugin.ts b/src/plugin.ts index ca15fce8..5f6bf08d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -25,15 +25,27 @@ export interface PluginSdk { getBrowserStorage(...namespace: string[]): Storage; } +const RESERVED_PLUGIN_NAMES: readonly string[] = ['__proto__', 'constructor', 'prototype']; + +export function isReservedPluginName(name: string): boolean { + return RESERVED_PLUGIN_NAMES.includes(name); +} + export namespace PluginSdk { export function register(name: string, factory: PluginFactory): void { - if (typeof window !== 'undefined') { - if (window.croctPlugins === undefined) { - window.croctPlugins = {}; - } + if (isReservedPluginName(name)) { + throw new Error(`The plugin name "${name}" is reserved and cannot be used.`); + } - window.croctPlugins[name] = factory; + if (typeof window === 'undefined') { + return; } + + if (window.croctPlugins === undefined) { + window.croctPlugins = {}; + } + + window.croctPlugins[name] = factory; } } diff --git a/test/plug.test.ts b/test/plug.test.ts index 48bb204c..6147b702 100644 --- a/test/plug.test.ts +++ b/test/plug.test.ts @@ -6,7 +6,7 @@ import {loadSlotContent} from '@croct/content'; import type {Logger} from '../src/sdk'; import type {Plugin, PluginFactory} from '../src/plugin'; import {PluginSdk} from '../src/plugin'; -import type {FetchResponse} from '../src/plug'; +import type {FetchResponse, PluginConfigurations} from '../src/plug'; import {GlobalPlug} from '../src/plug'; import {CDN_URL} from '../src/constants'; import {Token} from '../src/sdk/token'; @@ -73,6 +73,11 @@ describe('The Croct plug', () => { expect(override).toThrow('Another plugin is already registered with name "foo"'); }); + it('should disallow extending with a reserved plugin name', () => { + expect(() => croct.extend('__proto__', () => ({enable: jest.fn()}))) + .toThrow('The plugin name "__proto__" is reserved and cannot be used.'); + }); + it('should fail to initialize if the app ID is not specified and cannot be auto-detected', () => { expect(() => croct.plug()).toThrow('The app ID must be specified when it cannot be auto-detected.'); }); @@ -501,6 +506,127 @@ describe('The Croct plug', () => { ); }); + it('should reject a globally declared plugin using a reserved name', () => { + const fooFactory: PluginFactory = jest.fn(); + const registry: Record = {}; + + // Own, enumerable "__proto__" key (a plain literal would set the prototype instead). + Object.defineProperty(registry, '__proto__', { + value: fooFactory, + enumerable: true, + configurable: true, + writable: true, + }); + + window.croctPlugins = registry; + + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + croct.plug({ + appId: APP_ID, + logger: logger, + }); + + expect(fooFactory).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('The plugin name "__proto__" is reserved and cannot be used, ignoring it.'), + ); + }); + + it('should reject a configured plugin using a reserved name', () => { + const plugins: PluginConfigurations = {}; + + // Own, enumerable reserved key (a plain literal would set the prototype instead). + Object.defineProperty(plugins, '__proto__', { + value: true, + enumerable: true, + configurable: true, + writable: true, + }); + + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + croct.plug({ + appId: APP_ID, + logger: logger, + plugins: plugins, + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('The plugin name "__proto__" is reserved and cannot be used, ignoring it.'), + ); + }); + + it('should throw when registering a plugin with a reserved name through PluginSdk.register', () => { + expect(() => PluginSdk.register('__proto__', jest.fn())) + .toThrow('The plugin name "__proto__" is reserved and cannot be used.'); + + expect(window.croctPlugins).toBeUndefined(); + }); + + it('should do nothing when registering a plugin outside the browser', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'window'); + + Object.defineProperty(globalThis, 'window', {value: undefined, configurable: true}); + + try { + expect(() => PluginSdk.register('foo', jest.fn())).not.toThrow(); + } finally { + Object.defineProperty(globalThis, 'window', original!); + } + }); + + it('should treat an inherited configuration key as unconfigured for a late plugin', async () => { + const factory: PluginFactory = jest.fn(); + + croct.plug({appId: APP_ID}); + + await croct.plugged; + + // "toString" exists on Object.prototype but not as an own config key. + PluginSdk.register('toString', factory); + + expect(factory).toHaveBeenCalledWith(expect.objectContaining({options: {}})); + }); + + it('should warn that a plugin removed from the registry stays registered', async () => { + const fooFactory: PluginFactory = jest.fn(); + + window.croctPlugins = { + foo: fooFactory, + }; + + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + croct.plug({ + appId: APP_ID, + logger: logger, + }); + + await croct.plugged; + + delete window.croctPlugins?.foo; + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Plugin "foo" cannot be unregistered'), + ); + }); + it('should not enable a plugin registered after the plug is unplugged', async () => { const fooFactory: PluginFactory = jest.fn();