import Setting from '../../models/Setting'; import PluginService, { defaultPluginSetting } from '../../services/plugins/PluginService'; import { setupDatabaseAndSynchronizer, switchClient, withWarningSilenced } from '../../testing/test-utils'; import loadPlugins, { Props as LoadPluginsProps } from './loadPlugins'; import MockPluginRunner from './testing/MockPluginRunner'; import reducer, { State, defaultState } from '../../reducer'; import { Action, createStore } from 'redux'; import MockPlatformImplementation from './testing/MockPlatformImplementation'; import createTestPlugin from '../../testing/plugins/createTestPlugin'; import Plugin from './Plugin'; import shim from '../../shim'; import { PluginManifest } from './utils/types'; import { writeFile, mkdirp } from 'fs-extra'; import { join } from 'path'; const createMockReduxStore = () => { return createStore((state: State = defaultState, action: Action) => { return reducer(state, action); }); }; const defaultManifestProperties = { manifest_version: 1, version: '0.1.0', app_min_version: '2.3.4', platforms: ['desktop', 'mobile'], }; const platformImplementation = new MockPlatformImplementation(); describe('loadPlugins', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(1); await switchClient(1); }); afterEach(async () => { for (const id of PluginService.instance().pluginIds) { await PluginService.instance().unloadPlugin(id); } await PluginService.instance().destroy(); }); test('should load only enabled plugins', async () => { await createTestPlugin({ ...defaultManifestProperties, id: 'this.is.a.test.1', name: 'Disabled Plugin', }, { enabled: false }); const enabledPluginId = 'this.is.a.test.2'; await createTestPlugin({ ...defaultManifestProperties, id: enabledPluginId, name: 'Enabled Plugin', }); const pluginRunner = new MockPluginRunner(); const store = createMockReduxStore(); const loadPluginsOptions: LoadPluginsProps = { pluginRunner, pluginSettings: Setting.value('plugins.states'), platformImplementation, store, reloadAll: false, cancelEvent: { cancelled: false }, }; expect(Object.keys(PluginService.instance().plugins)).toHaveLength(0); await loadPlugins(loadPluginsOptions); await pluginRunner.waitForAllToBeRunning([enabledPluginId]); expect(pluginRunner.runningPluginIds).toMatchObject([enabledPluginId]); // No plugins were running before, so none should be stopped. expect(pluginRunner.stopCalledTimes).toBe(0); // Loading again should not re-run plugins await loadPlugins(loadPluginsOptions); // Should have tried to stop at most the disabled plugin (which is a no-op). expect(pluginRunner.stopCalledTimes).toBe(1); expect(pluginRunner.runningPluginIds).toMatchObject([enabledPluginId]); }); test('should reload all plugins when reloadAll is true', async () => { const enabledCount = 3; for (let i = 0; i < enabledCount; i++) { await createTestPlugin({ ...defaultManifestProperties, id: `joplin.test.plugin.${i}`, name: `Enabled Plugin ${i}`, }); } const disabledCount = 6; const disabledPlugins = []; for (let i = 0; i < disabledCount; i++) { disabledPlugins.push( await createTestPlugin({ ...defaultManifestProperties, id: `joplin.test.plugin.disabled.${i}`, name: `Disabled Plugin ${i}`, }, { enabled: false }), ); } const pluginRunner = new MockPluginRunner(); const store = createMockReduxStore(); const loadPluginsOptions: LoadPluginsProps = { pluginRunner, pluginSettings: Setting.value('plugins.states'), platformImplementation, store, reloadAll: true, cancelEvent: { cancelled: false }, }; await loadPlugins(loadPluginsOptions); let expectedRunningIds = ['joplin.test.plugin.0', 'joplin.test.plugin.1', 'joplin.test.plugin.2']; await pluginRunner.waitForAllToBeRunning(expectedRunningIds); // No additional plugins should be running. expect([...pluginRunner.runningPluginIds].sort()).toMatchObject(expectedRunningIds); // No plugins were running before -- there were no plugins to stop expect(pluginRunner.stopCalledTimes).toBe(0); const testPlugin = disabledPlugins[2]; expect(testPlugin.manifest.id).toBe('joplin.test.plugin.disabled.2'); // Enabling a plugin and reloading it should cause all plugins to load. testPlugin.setEnabled(true); await loadPlugins({ ...loadPluginsOptions, pluginSettings: Setting.value('plugins.states') }); expectedRunningIds = ['joplin.test.plugin.0', 'joplin.test.plugin.1', 'joplin.test.plugin.2', 'joplin.test.plugin.disabled.2']; await pluginRunner.waitForAllToBeRunning(expectedRunningIds); // Reloading all should stop all plugins and rerun enabled plugins, even // if not enabled previously. expect(pluginRunner.stopCalledTimes).toBe(disabledCount + enabledCount); expect([...pluginRunner.runningPluginIds].sort()).toMatchObject(expectedRunningIds); }); test('should skip extraction when jpl has not changed', async () => { const pluginId = 'joplin.test.plugin.packed'; await createTestPlugin({ ...defaultManifestProperties, id: pluginId, name: 'Test JPL Plugin', }, { format: 'jpl' }); const pluginRunner = new MockPluginRunner(); const store = createMockReduxStore(); const service = PluginService.instance(); service.initialize('2.3.4', platformImplementation, pluginRunner, store); const tarExtractSpy = jest.spyOn(shim.fsDriver(), 'tarExtract'); // First load should extract await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states')); expect(tarExtractSpy).toHaveBeenCalledTimes(1); // Second load with same file should skip extraction await service.unloadPlugin(pluginId); await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states')); expect(tarExtractSpy).toHaveBeenCalledTimes(1); // Recreating the jpl (different mtime/size) should trigger re-extraction await service.unloadPlugin(pluginId); await createTestPlugin({ ...defaultManifestProperties, id: pluginId, name: 'Test JPL Plugin', }, { format: 'jpl', onStart: '/* changed */' }); await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states')); expect(tarExtractSpy).toHaveBeenCalledTimes(2); tarExtractSpy.mockRestore(); }); test('should not load the script for disabled plugins', async () => { const createDirPlugin = async (manifest: PluginManifest, enabled: boolean) => { const dir = join(Setting.value('pluginDir'), manifest.id); await mkdirp(dir); await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest), 'utf-8'); await writeFile(join(dir, 'index.js'), 'joplin.plugins.register({ onStart: async function() {} });', 'utf-8'); const newStates = { ...Setting.value('plugins.states'), [manifest.id]: { ...defaultPluginSetting(), enabled }, }; Setting.setValue('plugins.states', newStates); }; const enabledId = 'joplin.test.plugin.enabled'; const disabledId = 'joplin.test.plugin.disabled'; await createDirPlugin({ ...defaultManifestProperties, id: enabledId, name: 'Enabled' }, true); await createDirPlugin({ ...defaultManifestProperties, id: disabledId, name: 'Disabled' }, false); const pluginRunner = new MockPluginRunner(); const store = createMockReduxStore(); PluginService.instance().initialize('2.3.4', platformImplementation, pluginRunner, store); await PluginService.instance().loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states')); expect(PluginService.instance().plugins[disabledId].scriptText).toBe(''); expect(PluginService.instance().plugins[enabledId].scriptText).not.toBe(''); }); test('should not block allPluginsStarted when a plugin fails to start', async () => { // This tests the fix for https://github.com/laurent22/joplin/issues/12793 // When a plugin crashes before calling register(), it should not block // other plugins from working. const goodPluginId = 'joplin.test.good.plugin'; await createTestPlugin({ ...defaultManifestProperties, id: goodPluginId, name: 'Good Plugin', }); const badPluginId = 'joplin.test.bad.plugin'; await createTestPlugin({ ...defaultManifestProperties, id: badPluginId, name: 'Bad Plugin', }); // Create a mock runner that throws for the bad plugin // and emits 'started' for good plugins (matching real behavior). const failingPluginRunner = new MockPluginRunner(); const originalRun = failingPluginRunner.run.bind(failingPluginRunner); failingPluginRunner.run = async (plugin: Plugin) => { if (plugin.manifest.id === badPluginId) { throw new Error('Simulated plugin crash before register()'); } await originalRun(plugin); // Simulate what JoplinPlugins.register() does after onStart() plugin.emit('started'); }; const store = createMockReduxStore(); const service = PluginService.instance(); service.initialize('2.3.4', platformImplementation, failingPluginRunner, store); await withWarningSilenced(/Plugin "joplin.test.bad.plugin" failed to start: Error: Simulated plugin crash/, async () => { await service.loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states')); }); // The bad plugin failed, but allPluginsStarted should still be true // because the fix marks failed plugins as "started". expect(service.allPluginsStarted).toBe(true); // The good plugin should still be running expect(failingPluginRunner.runningPluginIds).toContain(goodPluginId); // The bad plugin should not be running expect(failingPluginRunner.runningPluginIds).not.toContain(badPluginId); }); });