import { Injector } from '@angular/core'; import { provideTranslateService } from '@ngx-translate/core'; import { environment } from '../../../../../environments/environment'; import { AppI18nService } from '../../../../core/i18n'; import type { TojuPluginManifest } from '../../../../shared-kernel'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { PluginStoreService } from './plugin-store.service'; import { PluginHostService } from './plugin-host.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRegistryService } from './plugin-registry.service'; import { PluginCapabilityService } from './plugin-capability.service'; import type { PluginStoreEntry } from '../../domain/models/plugin-store.models'; const OFFICIAL_PLUGIN_SOURCE_URL = environment.pluginStore.defaultSourceUrls[0]; describe('PluginStoreService', () => { let fetchMock: ReturnType; let registerLocalManifest: ReturnType; let unregister: ReturnType; let storage: Storage; beforeEach(() => { storage = createMemoryStorage(); vi.stubGlobal('localStorage', storage); fetchMock = vi.fn(); registerLocalManifest = vi.fn((manifest: TojuPluginManifest, sourcePath?: string) => ({ enabled: true, manifest, sourcePath, state: 'validated', validationIssues: [] })); unregister = vi.fn(); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { storage.clear(); vi.unstubAllGlobals(); }); it('loads plugin entries from source manifests and resolves relative links', async () => { mockFetchResponses(fetchMock, { 'https://plugins.example.test/index.json': jsonResponse({ plugins: [ { author: 'Ada Example', description: 'Adds better channel tools.', github: 'https://github.com/example/better-channels', id: 'example.better-channels', image: './images/better.png', install: './better/toju-plugin.json', readme: './better/README.md', title: 'Better Channels', version: '1.2.0' } ], title: 'Example Plugins' }) }); const service = createService(registerLocalManifest, unregister); await new Promise((resolve) => setTimeout(resolve, 0)); await service.addSourceUrl('https://plugins.example.test/index.json#latest'); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']); expect(service.sources().some((source) => source.title === 'Example Plugins')).toBe(true); expect(service.availablePlugins()).toContainEqual(expect.objectContaining({ author: 'Ada Example', githubUrl: 'https://github.com/example/better-channels', id: 'example.better-channels', imageUrl: 'https://plugins.example.test/images/better.png', installUrl: 'https://plugins.example.test/better/toju-plugin.json', readmeUrl: 'https://plugins.example.test/better/README.md', sourceTitle: 'Example Plugins', title: 'Better Channels', version: '1.2.0' })); }); it('seeds the official plugin repository for new users', async () => { const service = createService(registerLocalManifest, unregister); await new Promise((resolve) => setTimeout(resolve, 0)); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]); expect(fetchMock).toHaveBeenCalledWith( OFFICIAL_PLUGIN_SOURCE_URL, expect.objectContaining({ headers: { Accept: 'application/json' } }) ); }); it('adds the official plugin repository when loading legacy source lists', () => { storage.setItem('metoyou_plugin_store', JSON.stringify({ installedPlugins: [], schemaVersion: 1, sourceUrls: ['https://plugins.example.test/index.json'] })); const service = createService(registerLocalManifest, unregister); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']); }); it('keeps user-removed default sources removed after schema migration', () => { storage.setItem('metoyou_plugin_store', JSON.stringify({ installedPlugins: [], schemaVersion: 2, sourceUrls: ['https://plugins.example.test/index.json'] })); const service = createService(registerLocalManifest, unregister); expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']); }); it('accepts local source manifest paths and resolves relative file links', async () => { const localSourceManifest = { plugins: [ { description: 'Local plugin source.', id: 'example.local-plugin', image: './icon.svg', install: './toju-plugin.json', readme: './README.md', title: 'Local Plugin', version: '1.0.0' } ], title: 'Local Plugins' }; const grantPluginReadRoot = vi.fn(async () => true); const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest))); const service = createService(registerLocalManifest, unregister, { grantPluginReadRoot, readFile }); await new Promise((resolve) => setTimeout(resolve, 0)); await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json'); expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything()); expect(grantPluginReadRoot).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin'); expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json'); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']); expect(service.availablePlugins()).toEqual([ expect.objectContaining({ id: 'example.local-plugin', imageUrl: 'file:///home/ludde/Desktop/TestPlugin/icon.svg', installUrl: 'file:///home/ludde/Desktop/TestPlugin/toju-plugin.json', readmeUrl: 'file:///home/ludde/Desktop/TestPlugin/README.md', sourceTitle: 'Local Plugins' }) ]); }); it('installs, detects updates, and uninstalls store plugins', async () => { const manifest = createManifest({ version: '1.0.0' }); const plugin = createStoreEntry({ version: '1.0.0' }); mockFetchResponses(fetchMock, { [plugin.installUrl ?? '']: jsonResponse(manifest) }); const service = createService(registerLocalManifest, unregister); await service.installPlugin(plugin); expect(registerLocalManifest).toHaveBeenCalledWith(manifest, plugin.installUrl); expect(service.installedPlugins()[0]?.manifest.id).toBe(plugin.id); expect(service.getActionLabel(plugin)).toBe('Uninstall'); expect(service.getActionLabel(createStoreEntry({ version: '1.1.0' }))).toBe('Update'); await service.uninstallPlugin(plugin.id); expect(unregister).toHaveBeenCalledWith(plugin.id); expect(service.installedPlugins()).toEqual([]); }); it('caches plugin bundle entrypoints locally before registering installed plugins', async () => { const manifest = createManifest({ entrypoint: './dist/main.js' }); const plugin = createStoreEntry({ bundleUrl: 'https://plugins.example.test/better/bundle.js', version: '1.0.0' }); const electronApi = { ensureDir: vi.fn(async () => true), getAppDataPath: vi.fn(async () => '/tmp/metoyou-user-data'), writeFile: vi.fn(async () => true) }; mockFetchResponses(fetchMock, { [plugin.bundleUrl ?? '']: textResponse('export function activate() {}'), [plugin.installUrl ?? '']: jsonResponse(manifest) }); const service = createService(registerLocalManifest, unregister, electronApi); await service.installPlugin(plugin); expect(electronApi.ensureDir).toHaveBeenCalledWith('/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0'); expect(electronApi.writeFile).toHaveBeenCalledWith( '/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/main.js', expect.any(String) ); expect(registerLocalManifest).toHaveBeenCalledWith( expect.objectContaining({ bundle: { entrypoint: './main.js', url: plugin.bundleUrl }, entrypoint: './main.js', id: manifest.id }), 'file:///tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/toju-plugin.json' ); }); it('loads plugin readmes as markdown text', async () => { const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' }); mockFetchResponses(fetchMock, { [plugin.readmeUrl ?? '']: textResponse('# Better Channels') }); const service = createService(registerLocalManifest, unregister); const readme = await service.loadReadme(plugin); expect(readme).toEqual({ markdown: '# Better Channels', pluginId: plugin.id, title: plugin.title, url: plugin.readmeUrl }); }); it('allows localhost HTTP plugin source URLs for local dev and E2E', async () => { const localSourceUrl = 'http://localhost:4200/plugins/e2e-plugin-source.json'; mockFetchResponses(fetchMock, { [localSourceUrl]: jsonResponse({ title: 'Local E2E Source', plugins: [] }) }); const service = createService(registerLocalManifest, unregister); await expect(service.addSourceUrl(localSourceUrl)).resolves.toBeUndefined(); expect(service.sourceUrls()).toContain(localSourceUrl); }); }); function mockFetchResponses(fetchMock: ReturnType, responses: Record): void { fetchMock.mockImplementation(async (url: RequestInfo | URL) => { const requestUrl = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; return responses[requestUrl] ?? (requestUrl === OFFICIAL_PLUGIN_SOURCE_URL ? jsonResponse({ plugins: [], title: 'Official Toju Plugins' }) : textResponse('')); }); } function createService( registerLocalManifest: ReturnType, unregister: ReturnType, electronApi: { ensureDir?: (dirPath: string) => Promise; getAppDataPath?: () => Promise; grantPluginReadRoot?: (rootPath: string) => Promise; readFile?: (filePath: string) => Promise; writeFile?: (filePath: string, data: string) => Promise; } | null = null ): PluginStoreService { const injector = Injector.create({ providers: [ provideTranslateService({ fallbackLang: 'en', lang: 'en' }), AppI18nService, PluginStoreService, { provide: ElectronBridgeService, useValue: { getApi: vi.fn(() => electronApi) } }, { provide: PluginHostService, useValue: { activatePersistedPlugins: vi.fn(async () => {}), deactivatePlugin: vi.fn(async () => {}), isPluginActive: vi.fn(() => false), registerLocalManifest } }, { provide: PluginDesktopStateService, useValue: { readJson: vi.fn(async (_key: string, fallback: unknown) => fallback), writeJson: vi.fn(async () => undefined) } }, { provide: PluginCapabilityService, useValue: { grantAll: vi.fn() } }, { provide: PluginRegistryService, useValue: { unregister } }, { provide: PluginRequirementService, useValue: {} } ] }); const service = injector.get(PluginStoreService); injector.get(AppI18nService).initialize(); return service; } function toBase64(value: string): string { return Buffer.from(value, 'utf8').toString('base64'); } function createManifest(overrides: Partial = {}): TojuPluginManifest { return { apiVersion: '1.0.0', compatibility: { minimumTojuVersion: '1.0.0' }, description: 'Adds better channel tools.', entrypoint: './dist/main.js', id: 'example.better-channels', kind: 'client', schemaVersion: 1, title: 'Better Channels', version: '1.0.0', ...overrides }; } function createStoreEntry(overrides: Partial = {}): PluginStoreEntry { return { author: 'Ada Example', description: 'Adds better channel tools.', githubUrl: 'https://github.com/example/better-channels', id: 'example.better-channels', imageUrl: 'https://plugins.example.test/images/better.png', installUrl: 'https://plugins.example.test/better/toju-plugin.json', readmeUrl: 'https://plugins.example.test/better/README.md', sourceTitle: 'Example Plugins', sourceUrl: 'https://plugins.example.test/index.json', title: 'Better Channels', version: '1.0.0', ...overrides }; } function jsonResponse(value: unknown): Response { return { json: vi.fn(async () => value), ok: true, status: 200, text: vi.fn(async () => JSON.stringify(value)) } as unknown as Response; } function textResponse(value: string): Response { return { json: vi.fn(async () => JSON.parse(value) as unknown), ok: true, status: 200, text: vi.fn(async () => value) } as unknown as Response; } function createMemoryStorage(): Storage { const values = new Map(); return { get length(): number { return values.size; }, clear: vi.fn(() => values.clear()), getItem: vi.fn((key: string) => values.get(key) ?? null), key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), removeItem: vi.fn((key: string) => values.delete(key)), setItem: vi.fn((key: string, value: string) => values.set(key, value)) }; }