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
408 lines
14 KiB
TypeScript
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))
|
|
};
|
|
}
|