open-design/apps/web/tests/components/HomeView.context-picker.test.tsx
吴杨帆 19142b0d11
fix(web): resolve skill vs type-chip routing conflicts (#2972) (#3031)
Clear the opposing selection when the user picks a skill or scenario
chip, and omit skillId when a scenario plugin is active on submit.
2026-05-27 04:37:21 +00:00

376 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
type InstalledPluginRecord,
type SkillSummary,
} from '@open-design/contracts';
import { HomeView } from '../../src/components/HomeView';
const SKILL: SkillSummary = {
id: 'prototype-lab',
name: 'Prototype Lab',
description: 'Create a focused prototype.',
triggers: ['prototype', 'flow'],
mode: 'prototype',
previewType: 'html',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: 'Design a focused onboarding prototype.',
aggregatesExamples: false,
};
const DECK_SKILL: SkillSummary = {
...SKILL,
id: 'deck-lab',
name: 'Deck Lab',
description: 'Create a focused slide deck.',
triggers: ['deck', 'slides'],
mode: 'deck',
examplePrompt: 'Design a focused investor deck.',
};
const WEB_PROTOTYPE_PLUGIN = makePlugin('example-web-prototype', 'Web Prototype');
function makePlugin(id: string, title: string): InstalledPluginRecord {
return {
id,
title,
version: '1.0.0',
sourceKind: 'bundled',
source: `/tmp/${id}`,
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
fsPath: `/tmp/${id}`,
installedAt: 0,
updatedAt: 0,
manifest: {
name: id,
title,
version: '1.0.0',
description: `${title} fixture`,
tags: ['fixture'],
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: `Hydrated query from ${title}`,
},
},
},
};
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('HomeView context picker', () => {
it('stages pasted files on Home and submits them as first-turn context', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
const file = new File(['brief'], 'brief.pdf', { type: 'application/pdf' });
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
expect(screen.getByTestId('home-hero-attach')).toBeTruthy();
fireEvent.paste(input, {
clipboardData: {
items: [
{
kind: 'file',
getAsFile: () => file,
},
],
},
});
await waitFor(() => expect(screen.getByText('brief.pdf')).toBeTruthy());
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: '',
pluginId: DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
attachments: [file],
}));
});
it('adds multiple @ plugins as context without applying or hydrating their query', async () => {
const plugins = [
makePlugin('chart-plugin', 'Chart Plugin'),
makePlugin('deck-plugin', 'Deck Plugin'),
];
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
fireEvent.change(input, { target: { value: 'Build @chart' } });
fireEvent.mouseDown(await screen.findByRole('option', { name: /chart plugin/i }));
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('Build @Chart Plugin');
expect(screen.getByTestId('home-hero-context-plugin-chart-plugin')).toBeTruthy();
});
fireEvent.change(input, { target: { value: `${(input as HTMLTextAreaElement).value} @deck` } });
fireEvent.mouseDown(await screen.findByRole('option', { name: /deck plugin/i }));
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('Build @Chart Plugin @Deck Plugin');
expect(screen.getByTestId('home-hero-context-plugin-chart-plugin')).toBeTruthy();
expect(screen.getByTestId('home-hero-context-plugin-deck-plugin')).toBeTruthy();
});
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
expect((input as HTMLTextAreaElement).value).not.toContain('Hydrated query');
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: 'Build @Chart Plugin @Deck Plugin',
pluginId: DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
contextPlugins: [
expect.objectContaining({ id: 'chart-plugin', title: 'Chart Plugin' }),
expect.objectContaining({ id: 'deck-plugin', title: 'Deck Plugin' }),
],
}));
});
it('binds a selected home skill to the created project payload', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
skills={[SKILL]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
fireEvent.change(input, { target: { value: '@proto' } });
fireEvent.mouseDown(screen.getByRole('option', { name: /prototype lab/i }));
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('@Prototype Lab');
expect(screen.getByTestId('home-hero-active-skill')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: '@Prototype Lab',
pluginId: DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
skillId: SKILL.id,
projectKind: 'prototype',
}));
});
it('clears an active type chip when the user picks a skill (#2972)', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
skills={[DECK_SKILL, SKILL]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Prototype');
});
const input = screen.getByTestId('home-hero-input');
fireEvent.change(input, { target: { value: '@deck' } });
fireEvent.mouseDown(screen.getByRole('option', { name: /deck lab/i }));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-skill')).toBeTruthy();
expect(screen.queryByTestId('home-hero-active-type-chip')).toBeNull();
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
skillId: DECK_SKILL.id,
projectKind: 'deck',
}));
expect(onSubmit.mock.calls[0]?.[0]?.pluginId).not.toBe('example-web-prototype');
});
it('clears an active skill when the user picks a type chip (#2972)', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return new Response(JSON.stringify({
appliedPlugin: {
snapshotId: 'snap-web-prototype',
pluginId: 'example-web-prototype',
pluginVersion: '1.0.0',
inputs: {},
},
contextItems: [],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
skills={[SKILL]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
fireEvent.change(input, { target: { value: '@proto' } });
fireEvent.mouseDown(screen.getByRole('option', { name: /prototype lab/i }));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-skill')).toBeTruthy();
});
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Prototype');
expect(screen.queryByTestId('home-hero-active-skill')).toBeNull();
});
fireEvent.change(input, { target: { value: 'Build a pricing-page prototype.' } });
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-web-prototype',
skillId: null,
projectKind: 'prototype',
})));
});
});