feat: plugins v1

This commit is contained in:
2026-04-29 01:14:14 +02:00
parent ec3802ade6
commit 6920f93b41
86 changed files with 9036 additions and 14 deletions

View File

@@ -0,0 +1,198 @@
import { Injector } from '@angular/core';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { PluginStoreService } from './plugin-store.service';
import { PluginHostService } from './plugin-host.service';
import { PluginRegistryService } from './plugin-registry.service';
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
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 () => {
fetchMock.mockResolvedValueOnce(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 service.addSourceUrl('https://plugins.example.test/index.json#latest');
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
expect(service.sources()[0]?.title).toBe('Example Plugins');
expect(service.availablePlugins()).toEqual([
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('installs, detects updates, and uninstalls store plugins', async () => {
const manifest = createManifest({ version: '1.0.0' });
const plugin = createStoreEntry({ version: '1.0.0' });
fetchMock.mockResolvedValueOnce(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');
service.uninstallPlugin(plugin.id);
expect(unregister).toHaveBeenCalledWith(plugin.id);
expect(service.installedPlugins()).toEqual([]);
});
it('loads plugin readmes as markdown text', async () => {
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
fetchMock.mockResolvedValueOnce(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
});
});
});
function createService(
registerLocalManifest: ReturnType<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>
): PluginStoreService {
const injector = Injector.create({
providers: [
PluginStoreService,
{
provide: PluginHostService,
useValue: { registerLocalManifest }
},
{
provide: PluginRegistryService,
useValue: { unregister }
}
]
});
return injector.get(PluginStoreService);
}
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))
};
}