feat: document contribution runtime APIs

This commit is contained in:
2026-06-28 16:41:39 +08:00
parent 8cab72cf29
commit 36846855b6
15 changed files with 319 additions and 19 deletions
+28
View File
@@ -14,6 +14,8 @@ describe('VerstakPluginAPI contract', () => {
expect(typeof api.capabilities.list).toBe('function');
expect(typeof api.commands.register).toBe('function');
expect(typeof api.commands.execute).toBe('function');
expect(typeof api.commands.executeFor).toBe('function');
expect(typeof api.contributions.list).toBe('function');
expect(typeof api.events.publish).toBe('function');
expect(typeof api.events.subscribe).toBe('function');
expect(typeof api.files.list).toBe('function');
@@ -188,6 +190,32 @@ describe('VerstakPluginAPI contract', () => {
await expect(api.commands.execute('cmd.plugin.echo', {})).rejects.toThrow('declared-but-unhandled');
});
test('contributions list and provider command execution', async () => {
const api = createMockPluginAPI('consumer.plugin', {
contributions: {
fileActions: [{
pluginId: 'provider.plugin',
id: 'provider.file.action',
label: 'Provider File Action',
handler: 'provider.command',
}],
},
});
const providerApi = createMockPluginAPI('provider.plugin');
await providerApi.commands.register('provider.command', async (args) => args.path);
await expect(api.contributions.list('fileActions')).resolves.toEqual([
expect.objectContaining({ pluginId: 'provider.plugin', id: 'provider.file.action' }),
]);
await expect(api.commands.executeFor('provider.plugin', 'provider.command', { path: 'Docs/readme.md' })).resolves.toEqual({
status: 'handled',
pluginId: 'provider.plugin',
commandId: 'provider.command',
result: 'Docs/readme.md',
});
});
test('events publish to subscribers and unsubscribe cleanly', async () => {
const api = createMockPluginAPI('event.plugin');
const received: unknown[] = [];
+9
View File
@@ -13,6 +13,7 @@ import type {
OpenResourceRequest,
OpenResourceResult,
PluginSettings,
RegisteredContributionPoints,
TrashResult,
WriteTextOptions,
} from './types';
@@ -88,6 +89,14 @@ export interface VerstakPluginAPI {
commands: {
register(commandId: string, handler: PluginCommandHandler): Promise<Unsubscribe>;
execute(commandId: string, args?: PluginCommandArgs): Promise<PluginCommandResult>;
executeFor(targetPluginId: string, commandId: string, args?: PluginCommandArgs): Promise<PluginCommandResult>;
};
contributions: {
list(): Promise<RegisteredContributionPoints>;
list<K extends keyof RegisteredContributionPoints>(
point: K
): Promise<NonNullable<RegisteredContributionPoints[K]>>;
};
events: {
+39 -7
View File
@@ -1,7 +1,17 @@
// Verstak Plugin SDK — Test Utilities
import type { PluginManifest, PluginState } from './types';
import type { VerstakPluginAPI } from './plugin-api';
import type { PluginManifest, PluginState, RegisteredContributionPoints } from './types';
import type { PluginCommandHandler, VerstakPluginAPI } from './plugin-api';
const mockCommandHandlers = new Map<string, PluginCommandHandler>();
function commandKey(pluginId: string, commandId: string): string {
return `${pluginId}:${commandId}`;
}
export interface MockPluginAPIOptions {
contributions?: RegisteredContributionPoints;
}
/**
* Создать тестовый manifest для unit-тестов.
@@ -40,9 +50,9 @@ export function createTestPluginState(overrides?: Partial<PluginState>): PluginS
/**
* Создать заглушку VerstakPluginAPI для тестов.
*/
export function createMockPluginAPI(pluginId = 'test.plugin'): VerstakPluginAPI {
export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPluginAPIOptions = {}): VerstakPluginAPI {
const settings: Record<string, unknown> = {};
const commands = new Map<string, (args: Record<string, unknown>) => unknown>();
const commands = new Map<string, PluginCommandHandler>();
const eventHandlers = new Map<string, Array<(event: any) => void>>();
const files = new Map<string, { type: 'file' | 'folder'; content?: string; modifiedAt: string }>();
files.set('', { type: 'folder', modifiedAt: new Date().toISOString() });
@@ -107,17 +117,39 @@ export function createMockPluginAPI(pluginId = 'test.plugin'): VerstakPluginAPI
list: vi.fn(async () => []),
},
commands: {
register: vi.fn(async (commandId: string, handler: (args: Record<string, unknown>) => unknown) => {
register: vi.fn(async (commandId: string, handler: PluginCommandHandler) => {
commands.set(commandId, handler);
return () => { commands.delete(commandId); };
mockCommandHandlers.set(commandKey(pluginId, commandId), handler);
return () => {
commands.delete(commandId);
mockCommandHandlers.delete(commandKey(pluginId, commandId));
};
}),
execute: vi.fn(async (commandId: string, args: Record<string, unknown> = {}) => {
const handler = commands.get(commandId);
if (!handler) {
throw new Error(`declared-but-unhandled: ${commandId}`);
}
return { status: 'handled' as const, pluginId, commandId, result: await handler(args) };
return { status: 'handled' as const, pluginId, commandId, result: await handler(args, { status: 'declared', pluginId, commandId, args }) };
}),
executeFor: vi.fn(async (targetPluginId: string, commandId: string, args: Record<string, unknown> = {}) => {
const handler = mockCommandHandlers.get(commandKey(targetPluginId, commandId));
if (!handler) {
throw new Error(`declared-but-unhandled: ${targetPluginId}:${commandId}`);
}
return {
status: 'handled' as const,
pluginId: targetPluginId,
commandId,
result: await handler(args, { status: 'declared', pluginId: targetPluginId, commandId, args }),
};
}),
},
contributions: {
list: vi.fn(async (point?: keyof RegisteredContributionPoints) => {
if (!point) return { ...(options.contributions || {}) };
return ([...((options.contributions && options.contributions[point]) || [])]) as any;
}) as VerstakPluginAPI['contributions']['list'],
},
events: {
publish: vi.fn(async (eventName: string, payload: Record<string, unknown> = {}) => {
+65
View File
@@ -251,6 +251,25 @@ export interface ContributionWorkspaceItem {
component: string;
}
export type RegisteredContribution<T> = T & {
pluginId: string;
};
export interface RegisteredContributionPoints {
views?: RegisteredContribution<ContributionView>[];
commands?: RegisteredContribution<ContributionCommand>[];
settingsPanels?: RegisteredContribution<ContributionSettingsPanel>[];
sidebarItems?: RegisteredContribution<ContributionSidebarItem>[];
fileActions?: RegisteredContribution<ContributionAction>[];
noteActions?: RegisteredContribution<ContributionAction>[];
contextMenuEntries?: RegisteredContribution<ContributionContextMenuEntry>[];
searchProviders?: RegisteredContribution<ContributionSearchProvider>[];
activityProviders?: RegisteredContribution<ContributionActivityProvider>[];
statusBarItems?: RegisteredContribution<ContributionStatusBarItem>[];
openProviders?: RegisteredContribution<ContributionOpenProvider>[];
workspaceItems?: RegisteredContribution<ContributionWorkspaceItem>[];
}
export interface OpenResourceContext {
sourcePluginId?: string;
sourceView?: 'files' | 'notes' | string;
@@ -380,6 +399,52 @@ export interface NoteSavedEvent extends VerstakEvent {
};
}
export interface WorkspaceCreatedEvent extends VerstakEvent {
name: 'workspace.created';
payload: {
operation: 'create';
workspaceRootPath: string;
workspaceName: string;
title?: string;
templateId?: string;
};
}
export interface WorkspaceRenamedEvent extends VerstakEvent {
name: 'workspace.renamed';
payload: {
operation: 'rename';
workspaceRootPath: string;
workspaceName: string;
previousWorkspaceRootPath: string;
previousWorkspaceName: string;
title?: string;
};
}
export interface WorkspaceTrashedEvent extends VerstakEvent {
name: 'workspace.trashed';
payload: {
operation: 'trash';
workspaceRootPath: string;
workspaceName: string;
title?: string;
trashId: string;
trashPath: string;
deletedAt: string;
};
}
export interface WorkspaceSelectedEvent extends VerstakEvent {
name: 'workspace.selected';
payload: {
operation: 'select';
workspaceRootPath: string;
workspaceName: string;
title?: string;
};
}
// Lifecycle events
export interface PluginEnabledEvent extends VerstakEvent {
name: 'plugin.enabled';