open-design/apps/web/tests/components/HomeView.media-options.test.tsx
Eli-tangerine 255bbc1d06
Show published user design systems on home (#2572)
Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-05-21 19:21:05 +08:00

600 lines
22 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HomeView } from '../../src/components/HomeView';
import type { DesignSystemSummary, PromptTemplateSummary } from '../../src/types';
const MEDIA_PLUGIN = pluginRecord('od-media-generation', 'Media generation');
const PROTOTYPE_PLUGIN = pluginRecord('example-web-prototype', 'Web prototype');
const HYPERFRAMES_PLUGIN = pluginRecord('example-hyperframes', 'HyperFrames');
const PROMPT_TEMPLATES: PromptTemplateSummary[] = [
{
id: 'image-product',
surface: 'image',
title: 'Image product concept',
summary: 'A polished product image prompt.',
category: 'product',
model: 'gpt-image-2',
aspect: '16:9',
source: { repo: 'open-design/image-prompts', license: 'MIT' },
},
{
id: 'video-reveal',
surface: 'video',
title: 'Video reveal',
summary: 'A short reveal video prompt.',
category: 'product',
model: 'doubao-seedance-2-0-260128',
aspect: '16:9',
source: { repo: 'open-design/video-prompts', license: 'MIT' },
},
{
id: 'hyperframes-caption',
surface: 'video',
title: 'HyperFrames captions',
summary: 'A caption-led HyperFrames prompt.',
category: 'motion',
model: 'hyperframes-html',
aspect: '16:9',
source: { repo: 'heygen-com/hyperframes', license: 'MIT' },
},
];
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
describe('HomeView media composer options', () => {
it('keeps media option popovers outside the clipped textarea highlight overlay', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('audio');
await openOption('audioType');
const popover = screen.getByTestId('home-hero-footer-option-audioType-menu');
expect(popover.closest('.home-hero__prompt-highlight')).toBeNull();
});
it('shows the correct option pills for Image, Video, HyperFrames, and Audio', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('image');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-model')).toBeTruthy());
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.getByTestId('home-hero-footer-option-designSystem')).toBeTruthy();
expect(screen.getByTestId('home-hero-footer-option-ratio')).toBeTruthy();
expect(screen.getByTestId('home-hero-footer-option-resolution')).toBeTruthy();
expect(screen.queryByTestId('home-hero-footer-option-duration')).toBeNull();
await clickHomeRailChip('video');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-duration')).toBeTruthy());
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.getByTestId('home-hero-footer-option-designSystem')).toBeTruthy();
expect(screen.getByTestId('home-hero-footer-option-model')).toBeTruthy();
expect(screen.getByTestId('home-hero-footer-option-ratio')).toBeTruthy();
expect(screen.getByTestId('home-hero-footer-option-resolution')).toBeTruthy();
await clickHomeRailChip('hyperframes');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-duration')).toBeTruthy());
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.getByTestId('home-hero-footer-option-ratio')).toBeTruthy();
expect(screen.queryByTestId('home-hero-footer-option-model')).toBeNull();
await clickHomeRailChip('audio');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-audioType')).toBeTruthy());
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.getByTestId('home-hero-footer-option-audioType')).toBeTruthy();
expect(screen.getByTestId('home-hero-footer-option-model')).toBeTruthy();
expect(screen.getByTestId('home-hero-footer-option-duration')).toBeTruthy();
expect(screen.queryByTestId('home-hero-prompt-slot-text')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-voice')).toBeNull();
});
it('includes only published user-created design systems in the Home style picker', async () => {
stubFetch();
renderHome({
designSystems: [
designSystem('user:acme-draft', 'Acme Draft System', 'user', 'draft'),
designSystem('user:acme-published', 'Acme Published System', 'user', 'published'),
designSystem('neutral-modern', 'Neutral Modern', 'built-in', 'published'),
],
});
await clickHomeRailChip('image');
await openOption('designSystem');
const menu = screen.getByTestId('home-hero-footer-option-designSystem-menu');
expect(within(menu).getByText('Personal')).toBeTruthy();
expect(within(menu).getByRole('option', { name: /Acme Published System/i })).toBeTruthy();
expect(within(menu).queryByRole('option', { name: /Acme Draft System/i })).toBeNull();
expect(within(menu).getByText('Official preset')).toBeTruthy();
});
it('switches media chips without opening the replacement dialog', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('image');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-model')).toBeTruthy());
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
fireEvent.change(screen.getByTestId('home-hero-input'), {
target: { value: 'Make this prompt personally tuned.' },
});
await clickHomeRailChip('video');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-duration')).toBeTruthy());
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
});
it('exposes only Speech and Sound effect in the Home Audio workflow', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('audio');
await openOption('audioType');
const audioTypes = optionTexts(screen.getByTestId('home-hero-footer-option-audioType-menu'));
expect(audioTypes).toEqual(['Speech', 'Sound effect']);
});
it('keeps the prompt empty when switching Speech and Sound effect audio sources', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('audio');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-audioType')).toBeTruthy());
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.queryByTestId('home-hero-prompt-slot-prompt')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-text')).toBeNull();
await chooseOption('audioType', 'sfx', 'Sound effect');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-audioType').textContent).toBe('Sound effect'));
expect(screen.queryByTestId('home-hero-prompt-slot-text')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-prompt')).toBeNull();
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
});
it('keeps media option edits from back-filling the textarea', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('audio');
await chooseOption('duration', '60', '60s');
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
await chooseOption('audioType', 'sfx', 'Sound effect');
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
});
it('hides the full selector grid for media surfaces', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('image');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-model')).toBeTruthy());
expect(screen.queryByRole('combobox', { name: 'Template' })).toBeNull();
expect(screen.queryByRole('combobox', { name: 'Model' })).toBeNull();
expect(screen.queryByRole('combobox', { name: 'Ratio' })).toBeNull();
await clickHomeRailChip('video');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-duration')).toBeTruthy());
expect(screen.queryByRole('combobox', { name: 'Duration' })).toBeNull();
expect(screen.queryByRole('combobox', { name: 'Template' })).toBeNull();
expect(screen.queryByRole('combobox', { name: 'Model' })).toBeNull();
expect(screen.queryByRole('combobox', { name: 'Ratio' })).toBeNull();
await clickHomeRailChip('audio');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-audioType')).toBeTruthy());
expect(screen.queryByRole('textbox', { name: 'Text' })).toBeNull();
expect(screen.queryByRole('combobox', { name: 'Audio type' })).toBeNull();
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
});
it('splits Video and HyperFrames templates into separate submitted metadata', async () => {
stubFetch();
const onSubmit = vi.fn();
renderHome({ onSubmit });
await clickHomeRailChip('video');
setHomePrompt('Make a product reveal video.');
await submitHome();
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
projectMetadata: expect.objectContaining({
promptTemplate: expect.objectContaining({ id: 'video-reveal' }),
}),
}));
});
onSubmit.mockClear();
await clickHomeRailChip('hyperframes');
setHomePrompt('Make a HyperFrames motion video.');
await submitHome();
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
projectMetadata: expect.objectContaining({
promptTemplate: expect.objectContaining({ id: 'hyperframes-caption' }),
}),
}));
});
});
it('updates submitted template metadata after media templates load', async () => {
stubFetch();
const onSubmit = vi.fn();
const props = homeProps({ onSubmit, promptTemplates: [] });
const view = render(<HomeView {...props} />);
await clickHomeRailChip('image');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-model')).toBeTruthy());
setHomePrompt('Create a campaign image.');
await submitHome();
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
projectMetadata: expect.not.objectContaining({
promptTemplate: expect.anything(),
}),
}));
});
onSubmit.mockClear();
view.rerender(<HomeView {...props} promptTemplates={PROMPT_TEMPLATES} />);
await submitHome();
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
projectMetadata: expect.objectContaining({
promptTemplate: expect.objectContaining({ id: 'image-product' }),
}),
}));
});
});
it('submits HyperFrames as a video project with the hyperframes-html model', async () => {
stubFetch();
const onSubmit = vi.fn();
renderHome({ onSubmit });
await clickHomeRailChip('hyperframes');
setHomePrompt('Create a HyperFrames launch bumper.');
await waitFor(() => expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
projectKind: 'video',
projectMetadata: expect.objectContaining({
kind: 'video',
videoModel: 'hyperframes-html',
}),
}));
});
it('does not add an ElevenLabs voice prompt when only the Audio tab is selected', async () => {
stubFetch();
renderHome();
await clickHomeRailChip('audio');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-model')).toBeTruthy());
expect(screen.queryByTestId('home-hero-prompt-slot-voice')).toBeNull();
await chooseOption('model', 'elevenlabs-v3');
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.queryByTestId('home-hero-prompt-slot-voice')).toBeNull();
});
it('keeps the prompt empty when ElevenLabs returns no voices', async () => {
stubFetch({ elevenLabsVoices: [] });
renderHome();
await clickHomeRailChip('audio');
await chooseOption('model', 'elevenlabs-v3');
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.queryByTestId('home-hero-prompt-slot-voice')).toBeNull();
});
it('keeps the prompt empty when ElevenLabs voice lookup fails', async () => {
stubFetch({ elevenLabsVoiceError: 'no ElevenLabs API key' });
renderHome();
await clickHomeRailChip('audio');
await chooseOption('model', 'elevenlabs-v3');
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
expect(screen.queryByTestId('home-hero-prompt-slot-voice')).toBeNull();
});
it('caps Sound effect duration options and normalizes stale speech durations', async () => {
stubFetch();
const onSubmit = vi.fn();
renderHome({ onSubmit });
await clickHomeRailChip('audio');
await chooseOption('duration', '60', '60s');
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
await chooseOption('audioType', 'sfx', 'Sound effect');
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe('');
await openOption('duration');
const durationOptions = optionTexts(screen.getByTestId('home-hero-footer-option-duration-menu'));
expect(durationOptions).toEqual(['5s', '10s', '15s', '30s']);
setHomePrompt('Create a crisp product notification sound.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginInputs: expect.objectContaining({ audioType: 'sfx', duration: 30 }),
}));
});
});
it('recomputes media metadata from textarea edits at submit time', async () => {
stubFetch();
const onSubmit = vi.fn();
renderHome({ onSubmit });
await clickHomeRailChip('audio');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-duration')).toBeTruthy());
const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement;
fireEvent.change(input, {
target: {
value: "Create premium product-studio audio from the user's brief using minimax-tts for 30 seconds: polished, restrained, clear, and brand-ready.",
},
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginInputs: expect.objectContaining({ duration: 30 }),
projectMetadata: expect.objectContaining({ audioDuration: 30 }),
}));
});
});
it('uses the Audio text input as the audio source and plugin subject', async () => {
stubFetch();
const onSubmit = vi.fn();
renderHome({ onSubmit });
await clickHomeRailChip('audio');
await waitFor(() => expect(screen.getByTestId('home-hero-footer-option-duration')).toBeTruthy());
const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement;
fireEvent.change(input, {
target: {
value: "Create premium product-studio audio from Welcome to Open Design. using minimax-tts for 10 seconds: polished, restrained, clear, and brand-ready.",
},
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginInputs: expect.objectContaining({
subject: 'Welcome to Open Design.',
text: 'Welcome to Open Design.',
}),
}));
});
});
it('preserves od-media-generation required inputs when applying media chips', async () => {
const fetchMock = stubFetch();
renderHome();
await clickHomeRailChip('image');
await waitFor(() => {
expect(fetchMock.mock.calls.some(([url, init]) => (
typeof url === 'string' &&
url.includes('/api/plugins/od-media-generation/apply') &&
JSON.parse(String(init?.body)).inputs.subject === 'a polished product concept'
))).toBe(true);
});
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-media-generation/apply')
));
expect(JSON.parse(String(applyCall?.[1]?.body)).inputs).toMatchObject({
mediaKind: 'image',
subject: 'a polished product concept',
style: 'cinematic, high-quality, on-brand',
aspect: '16:9',
ratio: '16:9',
});
});
});
function renderHome(overrides: Partial<React.ComponentProps<typeof HomeView>> = {}) {
return render(<HomeView {...homeProps(overrides)} />);
}
function homeProps(overrides: Partial<React.ComponentProps<typeof HomeView>> = {}): React.ComponentProps<typeof HomeView> {
return {
projects: [],
onSubmit: () => undefined,
onOpenProject: () => undefined,
onViewAllProjects: () => undefined,
promptTemplates: PROMPT_TEMPLATES,
...overrides,
};
}
function stubFetch(options: { elevenLabsVoices?: Array<{ voiceId: string; name: string; category?: string }>; elevenLabsVoiceError?: string } = {}) {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const fetchMock = vi.fn<typeof fetch>(async (url, init) => {
if (typeof url === 'string' && url === '/api/plugins') {
return json({ plugins: [MEDIA_PLUGIN, PROTOTYPE_PLUGIN, HYPERFRAMES_PLUGIN] });
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return json({ servers: [], templates: [] });
}
if (typeof url === 'string' && url.includes('/apply')) {
const pluginId = url.split('/api/plugins/')[1]?.split('/apply')[0] ?? 'od-media-generation';
if (pluginId === 'od-media-generation') {
const body = JSON.parse(String(init?.body ?? '{}')) as { inputs?: Record<string, unknown> };
const inputs = body.inputs ?? {};
if (!inputs.subject) {
return json({ error: 'missing_inputs', fields: ['subject'] }, 422);
}
}
return json(applyResult(pluginId));
}
if (typeof url === 'string' && url === '/api/media/providers/elevenlabs/voices?limit=100') {
if (options.elevenLabsVoiceError) {
return json({ error: options.elevenLabsVoiceError }, 400);
}
return json({
voices: options.elevenLabsVoices ?? [
{ voiceId: 'voice-rachel', name: 'Rachel', category: 'premade' },
],
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
async function openOption(name: string) {
const promptSlot = screen.queryByTestId(`home-hero-prompt-slot-${name}`);
if (promptSlot) {
fireEvent.pointerDown(promptSlot);
await waitFor(() => expect(screen.getByTestId(`home-hero-prompt-option-${name}`)).toBeTruthy());
return;
}
fireEvent.click(await screen.findByTestId(`home-hero-footer-option-${name}`));
await waitFor(() => expect(screen.getByTestId(`home-hero-footer-option-${name}-menu`)).toBeTruthy());
}
async function clickHomeRailChip(id: string) {
const activeChip = screen.queryByTestId('home-hero-active-type-chip');
if (activeChip) {
fireEvent.click(activeChip);
}
fireEvent.click(await screen.findByTestId(`home-hero-rail-${id}`));
}
function setHomePrompt(value: string) {
fireEvent.change(screen.getByTestId('home-hero-input'), {
target: { value },
});
}
async function submitHome() {
await waitFor(() => expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByTestId('home-hero-submit'));
}
function optionTexts(select: HTMLElement): string[] {
return within(select).getAllByRole('option').map((option) => option.textContent ?? '');
}
async function chooseOption(name: string, value: string, label = value) {
await openOption(name);
const promptSelect = screen.queryByTestId(`home-hero-prompt-option-${name}-select`);
if (promptSelect) {
fireEvent.change(promptSelect, { target: { value } });
return;
}
const menu = screen.getByTestId(`home-hero-footer-option-${name}-menu`);
const option = within(menu).getAllByRole('option').find((item) => {
const text = item.textContent ?? '';
return text.includes(label) || text.includes(value);
});
if (!option) throw new Error(`No option "${label}" for ${name}`);
fireEvent.click(option);
}
function pluginRecord(id: string, title: string) {
return {
id,
title,
version: '0.1.0',
trust: 'bundled' as const,
sourceKind: 'bundled' as const,
source: `/tmp/${id}`,
capabilitiesGranted: ['prompt:inject'],
fsPath: `/tmp/${id}`,
installedAt: 0,
updatedAt: 0,
manifest: {
name: id,
title,
version: '0.1.0',
description: title,
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: { query: 'Create media.' },
inputs: [],
},
},
};
}
function designSystem(
id: string,
title: string,
source: DesignSystemSummary['source'],
status: DesignSystemSummary['status'],
): DesignSystemSummary {
return {
id,
title,
source,
status,
category: source === 'user' ? 'Brand' : 'Starter',
summary: `${title} summary.`,
swatches: ['#111111', '#ffffff'],
surface: 'web',
isEditable: source === 'user',
};
}
function applyResult(pluginId: string) {
return {
query: 'Create media.',
contextItems: [],
inputs: [],
assets: [],
mcpServers: [],
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
projectMetadata: {},
appliedPlugin: {
snapshotId: `snap-${pluginId}`,
pluginId,
pluginVersion: '0.1.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',
},
};
}
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}