Files
Toju/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts
Myx eb51f043ac
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m12s
Queue Release Build / build-windows (push) Successful in 27m44s
Queue Release Build / build-linux (push) Successful in 48m1s
Queue Release Build / build-android (push) Successful in 22m7s
Queue Release Build / finalize (push) Successful in 2m42s
fix: Major bug cleanup pass 1
2026-06-09 17:59:54 +02:00

408 lines
14 KiB
TypeScript

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<typeof vi.fn>;
let registerLocalManifest: ReturnType<typeof vi.fn>;
let unregister: ReturnType<typeof vi.fn>;
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<typeof vi.fn>, responses: Record<string, Response>): 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<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>,
electronApi: {
ensureDir?: (dirPath: string) => Promise<boolean>;
getAppDataPath?: () => Promise<string>;
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
readFile?: (filePath: string) => Promise<string | null>;
writeFile?: (filePath: string, data: string) => Promise<boolean>;
} | 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> = {}): 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> = {}): 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<string, string>();
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))
};
}