open-design/apps/web/tests/components/HomeHero.plugin-picker.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

397 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
InputFieldSpec,
InstalledPluginRecord,
McpServerConfig,
PluginSourceKind,
SkillSummary,
TrustTier,
} from '@open-design/contracts';
import { HomeHero } from '../../src/components/HomeHero';
function makePlugin(
id: string,
title: string,
sourceKind: PluginSourceKind = 'bundled',
trust: TrustTier = 'bundled',
): InstalledPluginRecord {
return {
id,
title,
version: '1.0.0',
sourceKind,
source: '/tmp',
trust,
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: id,
version: '1.0.0',
title,
description: 'A plugin fixture',
tags: ['fixture'],
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
function makeSkill(id: string, name: string): SkillSummary {
return {
id,
name,
description: 'A skill fixture',
triggers: ['fixture'],
mode: 'prototype',
previewType: 'html',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: `Use ${name}`,
aggregatesExamples: false,
};
}
function makeMcp(id: string, label: string): McpServerConfig {
return {
id,
label,
transport: 'stdio',
enabled: true,
command: 'npx',
};
}
afterEach(() => {
cleanup();
});
describe('HomeHero plugin picker', () => {
it('opens plugin search from an @ token across community and my plugins', () => {
const onPromptChange = vi.fn();
const onPickPlugin = vi.fn();
render(
<HomeHero
prompt="Make @sam"
onPromptChange={onPromptChange}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[
makePlugin('sample-plugin', 'Sample Plugin'),
makePlugin('sample-user-plugin', 'Sample User Plugin', 'github', 'restricted'),
]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={onPickPlugin}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
expect(screen.getByTestId('home-hero-plugin-picker')).toBeTruthy();
expect(screen.getByText('Official')).toBeTruthy();
expect(screen.getByText('My plugin')).toBeTruthy();
fireEvent.mouseDown(screen.getByRole('option', { name: /sample user plugin/i }));
expect(onPickPlugin).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sample-user-plugin' }),
'Make @Sample User Plugin',
);
});
it('renders selected @ plugins inside the prompt and opens their details', () => {
const onOpenPluginDetails = vi.fn();
const sample = makePlugin('sample-plugin', 'Sample Plugin');
const helper = makePlugin('helper-plugin', 'Helper Plugin');
render(
<HomeHero
prompt="Use @Sample Plugin with @Helper Plugin"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
selectedPluginContexts={[sample, helper]}
onOpenPluginDetails={onOpenPluginDetails}
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={2}
error={null}
/>,
);
fireEvent.click(screen.getByTestId('home-hero-prompt-plugin-sample-plugin'));
expect(onOpenPluginDetails).toHaveBeenCalledWith(sample);
expect(screen.getByTestId('home-hero-prompt-plugin-helper-plugin')).toBeTruthy();
});
it('opens the context picker for a bare @ token even before results arrive', () => {
render(
<HomeHero
prompt="@"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
skillOptions={[]}
skillsLoading={false}
mcpOptions={[]}
mcpLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
expect(screen.getByTestId('home-hero-plugin-picker')).toBeTruthy();
expect(screen.getByRole('tab', { name: /plugins/i })).toBeTruthy();
expect(screen.getByRole('tab', { name: /skills/i })).toBeTruthy();
expect(screen.getByRole('tab', { name: /mcp/i })).toBeTruthy();
expect(screen.getByRole('tab', { name: /connectors/i })).toBeTruthy();
expect(screen.getByText('Search plugins, skills, MCP servers, and connectors.')).toBeTruthy();
});
it('can pick skills and MCP servers from the home @ picker', () => {
const onPickSkill = vi.fn();
const onPickMcp = vi.fn();
const skill = makeSkill('prototype-lab', 'Prototype Lab');
const mcp = makeMcp('linear', 'Linear');
const { rerender } = render(
<HomeHero
prompt="Make @proto"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
skillOptions={[skill]}
skillsLoading={false}
mcpOptions={[mcp]}
mcpLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickSkill={onPickSkill}
onPickMcp={onPickMcp}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
fireEvent.mouseDown(screen.getByRole('option', { name: /prototype lab/i }));
expect(onPickSkill).toHaveBeenCalledWith(skill, 'Make @Prototype Lab');
rerender(
<HomeHero
prompt="@lin"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
skillOptions={[skill]}
skillsLoading={false}
mcpOptions={[mcp]}
mcpLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickSkill={onPickSkill}
onPickMcp={onPickMcp}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
fireEvent.mouseDown(screen.getByRole('option', { name: /linear/i }));
expect(onPickMcp).toHaveBeenCalledWith(mcp, '@Linear');
});
it('does not submit while an IME composition is confirming text with Enter', () => {
const onSubmit = vi.fn();
render(
<HomeHero
prompt="做一个中文官网"
onPromptChange={() => undefined}
onSubmit={onSubmit}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
const input = screen.getByTestId('home-hero-input');
fireEvent.compositionStart(input);
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.compositionEnd(input);
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('does not pick a plugin while an IME composition is active', () => {
const onPickPlugin = vi.fn();
const onSubmit = vi.fn();
render(
<HomeHero
prompt="Make @sam"
onPromptChange={() => undefined}
onSubmit={onSubmit}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[makePlugin('sample-plugin', 'Sample Plugin')]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={onPickPlugin}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
const input = screen.getByTestId('home-hero-input');
fireEvent.compositionStart(input);
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onPickPlugin).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
it('highlights rendered plugin input values inside the prompt surface', () => {
const fields: InputFieldSpec[] = [
{
name: 'source',
label: 'Import source',
type: 'select',
options: ['folder', 'zip', 'github', 'marketplace'],
default: 'marketplace',
},
];
const prompt =
'Create a compact import receipt for community-import-smoke-test installed from marketplace.';
const { rerender } = render(
<HomeHero
prompt={prompt}
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Community Import Smoke Test"
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginInputFields={fields}
pluginInputValues={{ source: 'marketplace' }}
pluginInputTemplate="Create a compact import receipt for community-import-smoke-test installed from {{source}}."
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
// The inline pill is a read-only span so its width tracks the
// textarea text exactly. (See HomeHero.tsx for why <input>/<select>
// at this position caused the overlay/textarea caret drift.)
const slot = screen.getByTestId('home-hero-prompt-slot-source');
expect(slot.tagName).toBe('SPAN');
expect(slot.textContent).toBe('marketplace');
expect(slot.getAttribute('data-filled')).toBe('true');
// The structured inputs form below the textarea is suppressed
// when every plugin input is already referenced in the template
// — otherwise the form would render a second, identical labelled
// input for every slot pill shown inline, making the chat box
// look like it had grown a second composer.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
rerender(
<HomeHero
prompt={`${prompt} Extra user edit.`}
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Community Import Smoke Test"
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginInputFields={fields}
pluginInputValues={{ source: 'marketplace' }}
pluginInputTemplate="Create a compact import receipt for community-import-smoke-test installed from {{source}}."
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
expect(screen.queryByTestId('home-hero-prompt-slot-source')).toBeNull();
});
it('opens active plugin details from the active plugin chip', () => {
const onOpenPluginDetails = vi.fn();
const active = makePlugin('prototype-plugin', 'Prototype Plugin');
render(
<HomeHero
prompt="Build a prototype"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Prototype"
activePluginRecord={active}
activeChipId="prototype"
onClearActivePlugin={() => undefined}
onOpenPluginDetails={onOpenPluginDetails}
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
fireEvent.click(screen.getByTitle('Plugin: Prototype Plugin'));
expect(onOpenPluginDetails).toHaveBeenCalledWith(active);
});
});