open-design/apps/web/tests/components/PluginsView.test.tsx
lefarcen 41a33aed9e
Reapply "fix(web): demote Plugins and Integrations to nav rail footer (#1806)" (#2360) (#2397)
* Reapply "fix(web): demote Plugins and Integrations to nav rail footer (#1806)" (#2360)

This reverts commit 1ab8758045.

* fixup: align EntryHelpMenu Discord URL with #2386 update

The revert of #2360 brought back EntryHelpMenu.tsx as #1806 originally
added it, with DISCORD_URL = 'https://discord.gg/BYShPgWpq'. #2386 later
rotated the Discord invite to mHAjSMV6gz, but only in the places that
existed on main at the time (EntryShell avatar dropdown + the e2e test);
EntryHelpMenu didn't exist then, so it never got updated. The e2e test
the revert reintroduced asserts the new URL, so the component must
match.
2026-05-20 18:27:48 +08:00

677 lines
24 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPluginRecord, PluginSourceKind, TrustTier } from '@open-design/contracts';
import { PluginsView } from '../../src/components/PluginsView';
import {
addPluginMarketplace,
applyPlugin,
installPluginSource,
listPluginMarketplaces,
listPlugins,
refreshPluginMarketplace,
removePluginMarketplace,
setPluginMarketplaceTrust,
type PluginShareProjectOutcome,
uploadPluginFolder,
uploadPluginZip,
} from '../../src/state/projects';
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/state/projects', () => ({
addPluginMarketplace: vi.fn(),
applyPlugin: vi.fn(),
installPluginSource: vi.fn(),
listPluginMarketplaces: vi.fn(),
listPlugins: vi.fn(),
refreshPluginMarketplace: vi.fn(),
removePluginMarketplace: vi.fn(),
setPluginMarketplaceTrust: vi.fn(),
uninstallPlugin: vi.fn(),
uploadPluginFolder: vi.fn(),
uploadPluginZip: vi.fn(),
upgradePlugin: vi.fn(),
}));
function makePlugin(
id: string,
sourceKind: PluginSourceKind,
trust: TrustTier,
title = id === 'official-plugin' ? 'Official Plugin' : 'User Plugin',
description = `${id} description`,
): InstalledPluginRecord {
return {
id,
title,
version: '1.0.0',
sourceKind,
source: '/tmp',
trust,
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: id,
version: '1.0.0',
title: id,
description,
od: {
kind: 'scenario',
mode: 'prototype',
},
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
const mockedListPlugins = vi.mocked(listPlugins);
const mockedListMarketplaces = vi.mocked(listPluginMarketplaces);
const mockedInstallPluginSource = vi.mocked(installPluginSource);
const mockedAddMarketplace = vi.mocked(addPluginMarketplace);
const mockedRefreshMarketplace = vi.mocked(refreshPluginMarketplace);
const mockedRemoveMarketplace = vi.mocked(removePluginMarketplace);
const mockedSetMarketplaceTrust = vi.mocked(setPluginMarketplaceTrust);
const mockedApplyPlugin = vi.mocked(applyPlugin);
const mockedUploadPluginFolder = vi.mocked(uploadPluginFolder);
const mockedUploadPluginZip = vi.mocked(uploadPluginZip);
beforeEach(() => {
mockedListPlugins.mockResolvedValue([
makePlugin('official-plugin', 'bundled', 'bundled'),
makePlugin('user-plugin', 'github', 'restricted'),
]);
mockedListMarketplaces.mockResolvedValue([
{
id: 'catalog-1',
url: 'https://example.com/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Example Catalog',
version: '1.0.0',
plugins: [{
name: 'remote-plugin',
title: 'Remote Plugin',
source: 'github:owner/repo',
version: '1.2.0',
description: 'Remote catalog plugin.',
tags: ['deck'],
}],
},
},
]);
mockedAddMarketplace.mockResolvedValue({
ok: true,
message: 'Marketplace source added.',
});
mockedRefreshMarketplace.mockResolvedValue({
ok: true,
message: 'Marketplace source refreshed.',
});
mockedRemoveMarketplace.mockResolvedValue({
ok: true,
message: 'Marketplace source removed.',
});
mockedSetMarketplaceTrust.mockResolvedValue({
ok: true,
message: 'Marketplace trust updated.',
});
mockedInstallPluginSource.mockResolvedValue({
ok: true,
plugin: makePlugin('new-plugin', 'github', 'restricted'),
warnings: [],
message: 'Installed New Plugin.',
log: ['Parsing manifest'],
});
mockedUploadPluginZip.mockResolvedValue({
ok: true,
plugin: makePlugin('zip-plugin', 'user', 'restricted'),
warnings: [],
message: 'Installed Zip Plugin.',
log: [],
});
mockedUploadPluginFolder.mockResolvedValue({
ok: true,
plugin: makePlugin('folder-plugin', 'user', 'restricted'),
warnings: [],
message: 'Installed Folder Plugin.',
log: [],
});
mockedApplyPlugin.mockResolvedValue({
query: 'Make something.',
contextItems: [],
inputs: [],
assets: [],
mcpServers: [],
trust: 'restricted',
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
appliedPlugin: {
snapshotId: 'snap-1',
pluginId: 'official-plugin',
pluginVersion: '1.0.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 0,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
},
projectMetadata: {},
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('PluginsView', () => {
it('starts guided plugin creation from the Plugins hero', async () => {
const onCreatePlugin = vi.fn();
render(<PluginsView onCreatePlugin={onCreatePlugin} />);
fireEvent.click(await screen.findByTestId('plugins-create-button'));
expect(onCreatePlugin).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog', { name: 'Import a plugin' })).toBeNull();
});
it('shows installed plugins and available registry entries', async () => {
render(<PluginsView />);
expect(await screen.findByText('Installed plugins')).toBeTruthy();
expect(screen.getAllByText('User Plugin').length).toBeGreaterThan(0);
expect(screen.queryByText('Official Plugin')).toBeNull();
const availableTab = screen.getByTestId('plugins-tab-available');
const sourcesTab = screen.getByTestId('plugins-tab-sources');
expect(availableTab.getAttribute('aria-disabled')).toBeNull();
expect(sourcesTab.getAttribute('aria-disabled')).toBeNull();
fireEvent.click(availableTab);
expect(await screen.findByText('Remote Plugin')).toBeTruthy();
expect(screen.getAllByText('Example Catalog').length).toBeGreaterThan(0);
});
it('shows all installed plugins by default on the Plugins page', async () => {
const createPlugin = makePlugin('create-plugin', 'github', 'restricted', 'Create Plugin');
const importPlugin = makePlugin('import-plugin', 'github', 'restricted', 'Import Plugin');
importPlugin.manifest.od = {
kind: 'scenario',
mode: 'scenario',
taskKind: 'figma-migration',
};
mockedListPlugins.mockResolvedValue([createPlugin, importPlugin]);
mockedListMarketplaces.mockResolvedValue([]);
render(<PluginsView />);
const list = await screen.findByRole('list');
expect(
within(list)
.getAllByRole('listitem')
.map((item) => item.getAttribute('data-plugin-id'))
.sort(),
).toEqual(['create-plugin', 'import-plugin']);
const summary = screen.getByLabelText('Plugin summary');
expect(within(summary).getByText('2')).toBeTruthy();
expect(within(summary).getByText('Installed')).toBeTruthy();
});
it('hands installed plugin Use actions to the host shell', async () => {
const onUsePlugin = vi.fn();
render(<PluginsView onUsePlugin={onUsePlugin} />);
fireEvent.click(await screen.findByTestId('plugins-home-use-user-plugin'));
expect(onUsePlugin).toHaveBeenCalledWith(expect.objectContaining({
id: 'user-plugin',
title: 'User Plugin',
}), 'use');
expect(mockedApplyPlugin).not.toHaveBeenCalled();
});
it('hands Use with query actions to the host shell', async () => {
const onUsePlugin = vi.fn();
const user = makePlugin('query-plugin', 'github', 'restricted', 'Query Plugin');
user.manifest.od = {
...user.manifest.od,
useCase: { query: 'Make a query-backed artifact.' },
};
mockedListPlugins.mockResolvedValue([user]);
mockedListMarketplaces.mockResolvedValue([]);
render(<PluginsView onUsePlugin={onUsePlugin} />);
fireEvent.click(await screen.findByTestId('plugins-home-use-menu-query-plugin'));
fireEvent.click(screen.getByTestId('plugins-home-use-with-query-query-plugin'));
expect(onUsePlugin).toHaveBeenCalledWith(expect.objectContaining({
id: 'query-plugin',
title: 'Query Plugin',
}), 'use-with-query');
expect(mockedApplyPlugin).not.toHaveBeenCalled();
});
it('installs from a supported source string', async () => {
render(<PluginsView />);
expect(screen.queryByTestId('plugins-tab-import')).toBeNull();
fireEvent.click(await screen.findByTestId('plugins-import-button'));
expect(screen.getByRole('dialog', { name: 'Import a plugin' })).toBeTruthy();
expect(screen.queryByText('Create from template')).toBeNull();
const source = 'github:nexu-io/open-design@garnet-hemisphere/plugins/community/registry-starter';
fireEvent.change(screen.getByLabelText('GitHub, archive, or marketplace source'), {
target: { value: source },
});
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith(
source,
),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
expect(screen.getByTestId('plugins-tab-installed').getAttribute('aria-selected')).toBe('true');
expect(screen.getAllByText('User Plugin').length).toBeGreaterThan(0);
});
it('installs an available marketplace entry by name', async () => {
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
fireEvent.click(await screen.findByTestId('plugins-available-install-remote-plugin'));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith('remote-plugin'),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
expect(screen.getByTestId('plugins-tab-installed').getAttribute('aria-selected')).toBe('true');
});
it('opens details for available marketplace entries', async () => {
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
fireEvent.click(await screen.findByTestId('plugins-available-details-remote-plugin'));
const dialog = await screen.findByTestId('plugins-available-details-modal');
expect(within(dialog).getByText('Remote Plugin')).toBeTruthy();
expect(dialog.textContent).toContain('Remote catalog plugin.');
expect(dialog.textContent).toContain('github:owner/repo');
fireEvent.click(within(dialog).getByTestId('plugins-available-details-install-remote-plugin'));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith('remote-plugin@1.2.0'),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
await waitFor(() =>
expect(screen.queryByTestId('plugins-available-details-modal')).toBeNull(),
);
});
it('shows registry provenance, permissions, versions, and copyable install command in available details', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
mockedListMarketplaces.mockResolvedValue([
{
id: 'official',
url: 'https://example.com/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Official Registry',
version: '1.0.0',
plugins: [{
name: 'remote-plugin',
title: 'Remote Plugin',
source: 'github:owner/repo',
version: '1.2.0',
ref: 'v1.2.0',
integrity: 'sha256:latest',
description: 'Remote catalog plugin.',
permissions: ['prompt:inject', 'fs:read'],
capabilitiesSummary: ['Injects prompt context', 'Reads local design assets'],
versions: [
{
version: '1.2.0',
source: 'github:owner/repo',
ref: 'v1.2.0',
integrity: 'sha256:latest',
},
{
version: '1.1.0',
source: 'github:owner/repo',
ref: 'v1.1.0',
integrity: 'sha256:previous',
},
],
}],
},
},
]);
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
fireEvent.click(await screen.findByTestId('plugins-available-details-remote-plugin'));
const dialog = await screen.findByTestId('plugins-available-details-modal');
expect(within(dialog).getByTestId('plugins-available-provenance').textContent)
.toContain('from Official Registry · official · github:owner/repo@v1.2.0 · sha256:latest');
expect(within(dialog).getByText('Permissions')).toBeTruthy();
expect(within(dialog).getByText('prompt:inject')).toBeTruthy();
expect(within(dialog).getByText('fs:read')).toBeTruthy();
expect(within(dialog).getByText('Capability summary')).toBeTruthy();
expect(within(dialog).getByText('Injects prompt context')).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText('Plugin version'), {
target: { value: '1.1.0' },
});
expect(within(dialog).getByTestId('plugins-available-install-command').textContent)
.toContain('od plugin install remote-plugin@1.1.0');
expect(within(dialog).getByTestId('plugins-available-provenance').textContent)
.toContain('github:owner/repo@v1.1.0 · sha256:previous');
fireEvent.click(within(dialog).getByRole('button', { name: 'Copy install command' }));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('od plugin install remote-plugin@1.1.0'),
);
fireEvent.click(within(dialog).getByTestId('plugins-available-details-install-remote-plugin'));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith('remote-plugin@1.1.0'),
);
});
it('does not inherit latest ref and integrity for older sparse available versions', async () => {
mockedListMarketplaces.mockResolvedValue([
{
id: 'official',
url: 'https://example.com/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Official Registry',
version: '1.0.0',
plugins: [{
name: 'remote-plugin',
title: 'Remote Plugin',
source: 'github:owner/repo',
version: '1.2.0',
ref: 'v1.2.0',
integrity: 'sha256:latest',
description: 'Remote catalog plugin.',
versions: [
{ version: '1.2.0' },
{ version: '1.1.0' },
],
}],
},
},
]);
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
fireEvent.click(await screen.findByTestId('plugins-available-details-remote-plugin'));
const dialog = await screen.findByTestId('plugins-available-details-modal');
expect(within(dialog).getByTestId('plugins-available-provenance').textContent)
.toContain('github:owner/repo@v1.2.0 · sha256:latest');
fireEvent.change(within(dialog).getByLabelText('Plugin version'), {
target: { value: '1.1.0' },
});
expect(within(dialog).getByTestId('plugins-available-provenance').textContent)
.toContain('from Official Registry · official · github:owner/repo');
expect(within(dialog).getByTestId('plugins-available-provenance').textContent)
.not.toContain('v1.2.0');
expect(within(dialog).getByTestId('plugins-available-provenance').textContent)
.not.toContain('sha256:latest');
expect(within(dialog).queryByText('Ref')).toBeNull();
expect(within(dialog).queryByText('Integrity')).toBeNull();
});
it('filters available marketplace entries by source and search', async () => {
mockedListMarketplaces.mockResolvedValue([
{
id: 'catalog-1',
url: 'https://example.com/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Example Catalog',
version: '1.0.0',
plugins: [{
name: 'remote-plugin',
title: 'Remote Plugin',
source: 'github:owner/repo',
version: '1.2.0',
description: 'Remote catalog plugin.',
tags: ['deck'],
}],
},
},
{
id: 'catalog-2',
url: 'https://team.example.com/open-design-marketplace.json',
trust: 'restricted',
manifest: {
name: 'Team Catalog',
version: '1.0.0',
plugins: [{
name: 'figma-importer',
title: 'Figma Importer',
source: 'github:team/figma-importer',
version: '0.2.0',
description: 'Imports a Figma source.',
tags: ['figma', 'import'],
}],
},
},
]);
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
expect(await screen.findByText('Remote Plugin')).toBeTruthy();
expect(screen.getByText('Figma Importer')).toBeTruthy();
fireEvent.change(screen.getByLabelText('Search available plugins'), {
target: { value: 'figma' },
});
expect(await screen.findByText('Figma Importer')).toBeTruthy();
expect(screen.queryByText('Remote Plugin')).toBeNull();
fireEvent.change(screen.getByLabelText('Source'), {
target: { value: 'catalog-1' },
});
expect(await screen.findByText('No available entries match your filters.')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Clear available plugin search' }));
expect(await screen.findByText('Remote Plugin')).toBeTruthy();
expect(screen.queryByText('Figma Importer')).toBeNull();
});
it('keeps installed registry entries out of Available', async () => {
const official = makePlugin('official-plugin', 'bundled', 'bundled', 'Official Plugin');
official.sourceMarketplaceId = 'official';
official.sourceMarketplaceEntryName = 'open-design/official-plugin';
official.sourceMarketplaceEntryVersion = '1.0.0';
official.marketplaceTrust = 'official';
official.manifest.od = { ...official.manifest.od, hidden: true };
mockedListPlugins.mockImplementation(async (options?: { includeHidden?: boolean }) =>
options?.includeHidden ? [official] : [],
);
mockedListMarketplaces.mockResolvedValue([
{
id: 'official',
url: 'https://open-design.ai/marketplace/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Open Design Official',
version: '0.1.0',
plugins: [{
name: 'open-design/official-plugin',
title: 'Official Plugin',
source: 'github:nexu-io/open-design@main/plugins/_official/scenarios/official-plugin',
version: '1.0.0',
description: 'Bundled official starter.',
tags: ['official'],
}],
},
},
]);
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
expect(await screen.findByText(/Installed catalog entries are removed from Available/i)).toBeTruthy();
expect(screen.queryByText('Official Plugin')).toBeNull();
expect(screen.queryByRole('button', { name: 'Install' })).toBeNull();
expect(mockedListPlugins).toHaveBeenCalledWith({ includeHidden: true });
expect(mockedApplyPlugin).not.toHaveBeenCalled();
});
it('manages registry sources from the Sources tab', async () => {
render(<PluginsView />);
const sourceUrl =
'https://raw.githubusercontent.com/nexu-io/open-design/main/plugins/registry/community/open-design-marketplace.json';
fireEvent.click(await screen.findByTestId('plugins-tab-sources'));
fireEvent.change(screen.getByLabelText('Source URL'), {
target: { value: sourceUrl },
});
fireEvent.click(screen.getByRole('button', { name: 'Add source' }));
await waitFor(() =>
expect(mockedAddMarketplace).toHaveBeenCalledWith({
url: sourceUrl,
trust: 'restricted',
}),
);
expect(await screen.findByText('Marketplace source added.')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(mockedRefreshMarketplace).toHaveBeenCalledWith('catalog-1'));
fireEvent.change(screen.getByLabelText('Trust for Example Catalog'), {
target: { value: 'trusted' },
});
await waitFor(() =>
expect(mockedSetMarketplaceTrust).toHaveBeenCalledWith('catalog-1', 'trusted'),
);
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(mockedRemoveMarketplace).toHaveBeenCalledWith('catalog-1'));
});
it('uploads zip and folder plugins from the import dialog', async () => {
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-import-button'));
fireEvent.click(screen.getByRole('button', { name: /upload zip/i }));
const zip = new File(['zip-bytes'], 'plugin.zip', { type: 'application/zip' });
fireEvent.change(screen.getByTestId('plugins-zip-input'), {
target: { files: [zip] },
});
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() => expect(mockedUploadPluginZip).toHaveBeenCalledWith(zip));
expect(await screen.findByText('Installed Zip Plugin.')).toBeTruthy();
fireEvent.click(await screen.findByTestId('plugins-import-button'));
fireEvent.click(screen.getByRole('button', { name: /upload folder/i }));
const folderFile = new File(['{}'], 'open-design.json', { type: 'application/json' });
fireEvent.change(screen.getByTestId('plugins-folder-input'), {
target: { files: [folderFile] },
});
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() => expect(mockedUploadPluginFolder).toHaveBeenCalledWith([folderFile]));
expect(await screen.findByText('Installed Folder Plugin.')).toBeTruthy();
});
it('confirms a plugin share action before starting the GitHub repo task', async () => {
mockedListPlugins.mockResolvedValue([
makePlugin('official-plugin', 'bundled', 'bundled'),
makePlugin('user-plugin', 'github', 'restricted'),
makePlugin(
'od-plugin-publish-github',
'bundled',
'bundled',
'Publish Plugin to GitHub',
'Creates a public GitHub repository for a local Open Design plugin using the GitHub CLI.',
),
makePlugin(
'od-plugin-contribute-open-design',
'bundled',
'bundled',
'Contribute Plugin to Open Design',
'Opens a pull request that adds a local Open Design plugin to the Open Design community catalog.',
),
]);
const onCreatePluginShareProject = vi.fn(async (): Promise<PluginShareProjectOutcome> => ({
ok: true as const,
project: {
id: 'share-project',
name: 'Publish to GitHub: User Plugin',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
pendingPrompt: 'Publish it',
metadata: { kind: 'prototype' },
},
conversationId: 'conversation-1',
appliedPluginSnapshotId: 'snapshot-1',
actionPluginId: 'od-plugin-publish-github',
sourcePluginId: 'user-plugin',
stagedPath: 'plugin-source/user-plugin',
prompt: 'Publish it',
message: 'Created a Publish to GitHub task.',
}));
render(
<PluginsView
onCreatePluginShareProject={onCreatePluginShareProject}
/>,
);
const publish = await screen.findByTestId('plugins-home-publish-github-user-plugin');
expect(publish.textContent).toContain('Publish');
fireEvent.click(publish);
const dialog = await screen.findByRole('dialog', {
name: /Publish Plugin to GitHub for User Plugin/i,
});
expect(dialog.textContent).toContain('Creates a public GitHub repository');
expect(dialog.textContent).toContain('plugin-source/user-plugin');
expect(onCreatePluginShareProject).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByTestId('plugin-share-confirm-start'));
await waitFor(() =>
expect(onCreatePluginShareProject).toHaveBeenCalledWith(
'user-plugin',
'publish-github',
'en',
),
);
await waitFor(() =>
expect(screen.queryByTestId('plugin-share-confirm-modal')).toBeNull(),
);
});
});