Files
open-design/apps/web/tests/components/HomeView.prefill.test.tsx
7413c6daa1 feat(web): default Home to Prototype and switch types in composer (#8278)
* feat(web): switch creation types from the composer dropdown

* fix(web): limit type picker changes to dropdown interaction

* fix(web): separate creation type menu rows by one pixel

* fix(web): default Home to Prototype and migrate type picker coverage

* test(web): cover default selection and dropdown loading boundaries

* test(web): activate team catalog before context submission

* fix(web): keep loading type picker on the capsule surface

---------

Co-authored-by: wangchenglong <honam884844@gmail.com>
Co-authored-by: Siri-Ray <2667192167@qq.com>
2026-09-20 03:24:26 +00:00

2642 lines
97 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { pickHomeTemplate, homeTemplateTrigger } from '../helpers/home-template-picker';
import { act } from 'react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/components/home-hero/PlaceholderCarousel', () => ({
PlaceholderCarousel: () => null,
}));
vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/collab/useWorkspaceContext')>();
return {
...actual,
useWorkspaceContext: () => ({
context: null,
loading: false,
failure: 'unsupported' as const,
}),
};
});
import { HomeView } from '../../src/components/HomeView';
import { requestHomeChip } from '../../src/runtime/home-intent';
import { HOME_APPLY_TEMPLATE_EVENT } from '../../src/components/home-hero/chips';
import {
createPluginAuthoringHandoff,
createPluginUseHandoff,
PLUGIN_AUTHORING_DEFAULT_GOAL,
PLUGIN_AUTHORING_PROMPT,
} from '../../src/components/home-hero/plugin-authoring';
// HomeHero's `home-hero-input` is now the project composer's Lexical
// contenteditable, not a <textarea>. These helpers drive/read it through the
// live editor instead of synthetic `fireEvent.change` / `.value` (which are
// no-ops on a contenteditable).
import {
homeHeroPromptText,
setHomeHeroPrompt,
} from '../helpers/home-hero-lexical';
const AUTHORING_PLUGIN = {
id: 'od-plugin-authoring',
title: 'Plugin authoring',
version: '0.1.0',
trust: 'bundled' as const,
sourceKind: 'bundled' as const,
source: '/tmp/plugin-authoring',
capabilitiesGranted: ['prompt:inject'],
fsPath: '/tmp/plugin-authoring',
installedAt: 0,
updatedAt: 0,
manifest: {
name: 'od-plugin-authoring',
title: 'Plugin authoring',
version: '0.1.0',
description: 'Create plugins',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: { query: 'Create an OpenDesign plugin for {{pluginGoal}}.' },
inputs: [
{
name: 'pluginGoal',
type: 'string',
required: false,
default: PLUGIN_AUTHORING_DEFAULT_GOAL,
label: 'Plugin goal',
},
],
},
},
};
const DEFAULT_PLUGIN = {
...AUTHORING_PLUGIN,
id: 'od-new-generation',
title: 'New generation',
source: '/tmp/new-generation',
fsPath: '/tmp/new-generation',
manifest: {
...AUTHORING_PLUGIN.manifest,
name: 'od-new-generation',
title: 'New generation',
description: 'Create new design artifacts',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: { query: 'Create a plugin.' },
},
},
};
const DOCUMENT_NEW_GENERATION_PLUGIN = {
...DEFAULT_PLUGIN,
manifest: {
...DEFAULT_PLUGIN.manifest,
od: {
...DEFAULT_PLUGIN.manifest.od,
useCase: {
query: 'Generate a {{artifactKind}} for {{audience}} on {{topic}}.',
},
inputs: [
{
name: 'artifactKind',
type: 'string',
required: true,
label: 'Artifact kind',
},
{
name: 'audience',
type: 'string',
required: true,
label: 'Audience',
},
{
name: 'topic',
type: 'string',
required: true,
label: 'Topic',
},
],
},
},
};
const HIDDEN_DEFAULT_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'od-default',
title: 'Default design router',
source: '/tmp/default-router',
fsPath: '/tmp/default-router',
manifest: {
...DEFAULT_PLUGIN.manifest,
name: 'od-default',
title: 'Default design router',
od: {
...DEFAULT_PLUGIN.manifest.od,
hidden: true,
},
},
};
// Keep the legacy web-prototype record available for explicit presets and for
// ordinary routes such as Wireframe / Mobile. The exact UI Mockup automatic
// route is asserted below to bypass this record entirely in favor of OD Next.
const WEB_PROTOTYPE_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-web-prototype',
title: 'Web Prototype',
source: '/tmp/web-prototype',
fsPath: '/tmp/web-prototype',
manifest: {
...DEFAULT_PLUGIN.manifest,
name: 'example-web-prototype',
title: 'Web Prototype',
description: 'General-purpose desktop web prototype.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: 'Build a {{fidelity}} {{artifactKind}} for {{audience}} using {{designSystem}} from {{template}}.',
},
inputs: [
{
name: 'artifactKind',
type: 'string',
required: true,
default: 'web prototype',
label: 'Artifact kind',
},
{
name: 'fidelity',
type: 'select',
required: true,
options: ['wireframe', 'high-fidelity'],
default: 'high-fidelity',
label: 'Fidelity',
},
{
name: 'audience',
type: 'string',
required: true,
default: 'product evaluators',
label: 'Audience',
},
{
name: 'designSystem',
type: 'string',
default: 'the active project design system',
label: 'Design system',
},
{
name: 'template',
type: 'string',
default: 'the bundled web prototype seed',
label: 'Template',
},
],
},
},
};
// Same prototype scenario, carrying the facet fields (`od.mode`, `tags`) the
// Community taxonomy reads. 移动应用 narrows the example pool to the Apps facet
// (`filteredExamplePlugins` in HomeHero), so an example card only renders under
// that scene for a plugin the taxonomy can actually place there.
const FACETED_WEB_PROTOTYPE_PLUGIN = {
...WEB_PROTOTYPE_PLUGIN,
manifest: {
...WEB_PROTOTYPE_PLUGIN.manifest,
tags: ['mobile'],
od: {
...WEB_PROTOTYPE_PLUGIN.manifest.od,
mode: 'prototype',
},
},
};
const SIMPLE_DECK_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-simple-deck',
title: 'Simple Deck',
source: '/tmp/simple-deck',
fsPath: '/tmp/simple-deck',
manifest: {
...DEFAULT_PLUGIN.manifest,
name: 'example-simple-deck',
title: 'Simple Deck',
description: 'Single-file horizontal-swipe HTML deck.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: 'Create a {{deckType}} for {{audience}} about {{topic}} with {{slideCount}}. Speaker notes: {{speakerNotes}}. Use {{designSystem}}.',
},
inputs: [
{
name: 'deckType',
type: 'select',
required: true,
options: ['pitch deck', 'product overview', 'study deck'],
default: 'pitch deck',
label: 'Deck type',
},
{
name: 'topic',
type: 'string',
required: true,
default: 'the user brief',
label: 'Topic',
},
{
name: 'audience',
type: 'string',
required: true,
default: 'decision makers',
label: 'Audience',
},
{
name: 'slideCount',
type: 'select',
required: true,
options: ['5-10 pages', '10-15 pages', '15-20 pages', '20-25 pages', '25-30 pages'],
default: '10-15 pages',
label: 'Pages',
},
{
name: 'speakerNotes',
type: 'select',
options: ['include speaker notes', 'no speaker notes'],
default: 'include speaker notes',
label: 'Speaker notes',
},
{
name: 'designSystem',
type: 'string',
default: 'the active project design system',
label: 'Design system',
},
],
},
},
};
const LIVE_ARTIFACT_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-live-artifact',
title: 'Live Artifact',
source: '/tmp/live-artifact',
fsPath: '/tmp/live-artifact',
manifest: {
...DEFAULT_PLUGIN.manifest,
name: 'example-live-artifact',
title: 'Live Artifact',
description: 'Create refreshable, auditable OpenDesign artifacts.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
mode: 'prototype',
scenario: 'live',
useCase: {
query: 'Create refreshable, auditable OpenDesign artifacts backed by connector or local data.',
},
context: {
skills: [{ path: './SKILL.md' }],
},
pipeline: {
stages: [{ id: 'generate', atoms: ['file-write', 'live-artifact'] }],
},
},
},
};
const LIVE_ARTIFACT_IMAGE_TEMPLATE_PLUGIN = {
...LIVE_ARTIFACT_PLUGIN,
id: 'image-template-notion-team-dashboard-live-artifact',
title: 'Notion live artifact',
source: '/tmp/notion-live-artifact',
fsPath: '/tmp/notion-live-artifact',
manifest: {
...LIVE_ARTIFACT_PLUGIN.manifest,
name: 'image-template-notion-team-dashboard-live-artifact',
title: 'Notion live artifact',
description: 'Create a live Notion dashboard artifact.',
od: {
...LIVE_ARTIFACT_PLUGIN.manifest.od,
mode: 'image',
surface: 'image',
useCase: {
query: 'Create a refreshable Notion dashboard live artifact.',
},
},
},
};
const AUTHORING_DEFAULT_SCENARIO_INPUTS = {
artifactKind: 'OpenDesign plugin',
audience: 'OpenDesign plugin authors',
topic: 'packaging a reusable workflow as an OpenDesign plugin',
};
const REFLY_DESIGN_SYSTEM = {
id: 'ds-refly',
title: 'Refly Design System',
category: 'Productivity & SaaS',
summary: 'Refly defaults',
source: 'user' as const,
status: 'published' as const,
isEditable: true,
};
const AUTHORING_APPLY_RESULT = {
query: 'Create a plugin.',
contextItems: [],
inputs: AUTHORING_PLUGIN.manifest.od.inputs,
assets: [],
mcpServers: [],
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
appliedPlugin: {
snapshotId: 'snap-authoring',
pluginId: 'od-plugin-authoring',
pluginVersion: '0.1.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: { pluginGoal: PLUGIN_AUTHORING_DEFAULT_GOAL },
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 0,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
},
projectMetadata: {},
};
const DEFAULT_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
inputs: [],
appliedPlugin: {
...AUTHORING_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-default',
pluginId: 'od-new-generation',
inputs: AUTHORING_DEFAULT_SCENARIO_INPUTS,
},
};
const DOCUMENT_NEW_GENERATION_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
query: DOCUMENT_NEW_GENERATION_PLUGIN.manifest.od.useCase.query,
inputs: DOCUMENT_NEW_GENERATION_PLUGIN.manifest.od.inputs,
appliedPlugin: {
...AUTHORING_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-document-new-generation',
pluginId: 'od-new-generation',
inputs: {
artifactKind: 'document',
audience: 'readers',
topic: 'the user brief',
},
},
};
const WEB_PROTOTYPE_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
query: WEB_PROTOTYPE_PLUGIN.manifest.od.useCase.query,
inputs: WEB_PROTOTYPE_PLUGIN.manifest.od.inputs,
appliedPlugin: {
...AUTHORING_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-web-prototype',
pluginId: 'example-web-prototype',
inputs: {
artifactKind: 'web prototype',
fidelity: 'high-fidelity',
audience: 'product evaluators',
designSystem: 'the active project design system',
template: 'the bundled web prototype seed',
},
},
};
// A plugin whose useCase.query is a generator-facing meta-instruction (not a
// human-readable brief). use-with-query must surface the description instead,
// matching the Home example-prompt cards.
const META_INSTRUCTION_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-meta-landing',
title: 'Meta Landing',
source: '/tmp/meta-landing',
fsPath: '/tmp/meta-landing',
manifest: {
...DEFAULT_PLUGIN.manifest,
name: 'example-meta-landing',
title: 'Meta Landing',
description: 'Cinematic parallax landing page.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: 'Follow the en field verbatim; start from the bundled example.html.',
},
},
},
};
const META_INSTRUCTION_APPLY_RESULT = {
...WEB_PROTOTYPE_APPLY_RESULT,
query: META_INSTRUCTION_PLUGIN.manifest.od.useCase.query,
inputs: [],
appliedPlugin: {
...WEB_PROTOTYPE_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-meta-landing',
pluginId: 'example-meta-landing',
inputs: {},
},
};
// Same deck scenario, but with the facet fields (`od.mode`, `od.category`) the
// Community subcategory taxonomy reads — so Deck's dynamic second-level rail
// actually renders a tab to click.
const FACETED_DECK_PLUGIN = {
...SIMPLE_DECK_PLUGIN,
manifest: {
...SIMPLE_DECK_PLUGIN.manifest,
od: {
...SIMPLE_DECK_PLUGIN.manifest.od,
mode: 'deck',
category: 'fundraising-pitch',
},
},
};
const SIMPLE_DECK_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
query: SIMPLE_DECK_PLUGIN.manifest.od.useCase.query,
inputs: SIMPLE_DECK_PLUGIN.manifest.od.inputs,
appliedPlugin: {
...AUTHORING_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-simple-deck',
pluginId: 'example-simple-deck',
inputs: {
deckType: 'pitch deck',
topic: 'the user brief',
audience: 'decision makers',
slideCount: '10-15 pages',
speakerNotes: 'include speaker notes',
designSystem: 'the active project design system',
},
},
};
const LIVE_ARTIFACT_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
query: LIVE_ARTIFACT_PLUGIN.manifest.od.useCase.query,
inputs: [],
appliedPlugin: {
...AUTHORING_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-live-artifact',
pluginId: 'example-live-artifact',
inputs: {},
},
projectMetadata: {
skillId: 'live-artifact',
},
};
function stubAnimationFrame() {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
const id = window.setTimeout(() => cb(window.performance.now()), 0);
return id;
});
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
window.clearTimeout(id);
});
}
describe('HomeView prompt handoff', () => {
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
window.localStorage.clear();
window.sessionStorage.clear();
});
it('keeps the existing sending state visible and preserves the draft when submit fails', async () => {
let resolveSubmit: (accepted: boolean) => void = () => undefined;
const submitResult = new Promise<boolean>((resolve) => {
resolveSubmit = resolve;
});
vi.stubGlobal('fetch', 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' },
});
}
throw new Error(`unexpected fetch ${url}`);
}));
stubAnimationFrame();
render(
<HomeView
projects={[]}
onSubmit={() => submitResult}
onOpenProject={() => undefined}
/>,
);
await setPromptAndSettle('Create an image of a quiet reading room.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => {
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(true);
});
// The arrow never flashes a busy treatment; the disabled lock is the whole
// in-flight state.
expect(screen.getByTestId('home-hero-submit').getAttribute('aria-busy')).toBe('false');
expect(homeHeroPromptValue()).toBe('Create an image of a quiet reading room.');
await act(async () => {
resolveSubmit(false);
await submitResult;
});
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to start the run. Try again.',
);
expect(homeHeroPromptValue()).toBe('Create an image of a quiet reading room.');
expect(screen.getByTestId('home-hero-submit').getAttribute('aria-busy')).toBe('false');
});
it('defaults a fresh Home to Prototype without applying a plugin or changing the draft', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (url === '/api/plugins') return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN, SIMPLE_DECK_PLUGIN] }));
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
window.localStorage.setItem('open-design:home-composer:prompt', 'Keep my draft');
const onSubmit = vi.fn();
render(<HomeView projects={[]} onSubmit={onSubmit} onOpenProject={() => undefined} />);
await waitFor(() => expect(screen.getByTestId('home-hero-template-picker').getAttribute('data-type')).toBe('prototype'));
expect(homeHeroPromptValue()).toBe('Keep my draft');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: 'Keep my draft', projectKind: 'prototype', automaticStrategyTaskProfile: 'prototype',
})));
});
it('waits for the initial catalog before enabling a restored draft submission', async () => {
let resolveCatalog!: (response: Response) => void;
const catalog = new Promise<Response>((resolve) => { resolveCatalog = resolve; });
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async (url) => {
if (url === '/api/plugins') return catalog;
throw new Error(`unexpected fetch ${url}`);
}));
window.localStorage.setItem('open-design:home-composer:prompt', 'My first prototype');
const onSubmit = vi.fn();
render(<HomeView projects={[]} onSubmit={onSubmit} onOpenProject={() => undefined} />);
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).not.toHaveBeenCalled();
await act(async () => {
resolveCatalog(new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] })));
await catalog;
});
await waitFor(() => expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false));
expect(screen.getByTestId('home-hero-template-picker').getAttribute('data-type')).toBe('prototype');
});
it('restores a saved Deck selection instead of seeding Prototype', async () => {
window.localStorage.setItem('open-design:home-composer:chip', JSON.stringify({
chipId: 'deck', pluginId: SIMPLE_DECK_PLUGIN.id, projectKind: 'deck',
}));
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN, SIMPLE_DECK_PLUGIN] }))));
stubAnimationFrame();
render(<HomeView projects={[]} onSubmit={() => undefined} onOpenProject={() => undefined} />);
await waitFor(() => expect(screen.getByTestId('home-hero-template-picker').getAttribute('data-type')).toBe('deck'));
await pickHomeTemplate('prototype');
expect(screen.getByTestId('home-hero-template-picker').getAttribute('data-type')).toBe('prototype');
});
it('keeps an explicit queued type ahead of the fresh-home default', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN, SIMPLE_DECK_PLUGIN] }))));
stubAnimationFrame();
requestHomeChip('deck');
render(<HomeView projects={[]} onSubmit={() => undefined} onOpenProject={() => undefined} />);
await waitFor(() => expect(screen.getByTestId('home-hero-template-picker').getAttribute('data-type')).toBe('deck'));
expect(homeHeroPromptValue()).toBe('');
});
it('leaves the dock type to its host', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }))));
render(<HomeView variant="dock" projects={[]} onSubmit={() => undefined} onOpenProject={() => undefined} />);
await waitFor(() => expect(homeTemplateTrigger().disabled).toBe(false));
expect(screen.getByTestId('home-hero-template-picker').getAttribute('data-type')).toBeNull();
});
it('keeps creation types actionable while an expired plugin cache refreshes after a project round trip', async () => {
let resolveRefresh: (response: Response) => void = () => undefined;
const refreshResponse = new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
let pluginListReads = 0;
const pluginResponse = () => new Response(
JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
pluginListReads += 1;
return pluginListReads === 1 ? pluginResponse() : refreshResponse;
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const firstHome = render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
/>,
);
const firstTrigger = homeTemplateTrigger();
await waitFor(() => expect((firstTrigger as HTMLButtonElement).disabled).toBe(false));
firstHome.unmount();
// Project pages commonly stay open longer than the plugin cache TTL. Model
// that route round trip without a real sleep, then hold the background
// refresh open so actionability cannot accidentally depend on network time.
const now = Date.now();
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now + 10_001);
try {
const returnedHome = render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
/>,
);
expect(pluginListReads).toBe(2);
expect(
(homeTemplateTrigger() as HTMLButtonElement).disabled,
).toBe(false);
await act(async () => {
resolveRefresh(pluginResponse());
await refreshResponse;
});
returnedHome.unmount();
} finally {
dateNow.mockRestore();
}
});
it('consumes a plugin authoring handoff once and focuses the textarea', async () => {
let resolveApply: (response: Response) => void = () => undefined;
const applyResponse = new Promise<Response>((resolve) => {
resolveApply = resolve;
});
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN, WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
return applyResponse;
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
promptHandoff={createPluginAuthoringHandoff(1)}
/>,
);
const input = await screen.findByTestId('home-hero-input');
await waitFor(() => {
expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT);
expect(document.activeElement).toBe(input);
});
const inputCard = input.closest('.home-hero__input-card') as HTMLElement | null;
expect(inputCard?.classList.contains('home-hero__input-card--compact-authoring')).toBe(true);
expect(inputCard?.style.getPropertyValue('--home-hero-prompt-max-height')).toBe('132px');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
resolveApply(new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await waitFor(() => {
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
});
await setPromptAndSettle('User edited prompt');
rerender(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
promptHandoff={createPluginAuthoringHandoff(1)}
/>,
);
expect(homeHeroPromptText()).toBe('User edited prompt');
});
it('uses the same authoring prompt from the Home rail chip', async () => {
vi.stubGlobal('fetch', vi.fn(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN, WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
}));
stubAnimationFrame();
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
/>,
);
await clickHomeShortcut('create-plugin');
const input = await screen.findByTestId('home-hero-input');
await waitFor(() => {
expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT);
expect(document.activeElement).toBe(input);
});
expect(screen.queryByRole('alert')).toBeNull();
});
it('asks before replacing an edited Home draft with the rail create-plugin prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN, WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
await setPromptAndSettle('Keep my custom plugin brief');
await clickHomeShortcut('create-plugin');
const dialog = await screen.findByRole('dialog', { name: /replace current prompt/i });
expect(homeHeroPromptText()).toBe('Keep my custom plugin brief');
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')
))).toBe(false);
fireEvent.click(within(dialog).getByRole('button', { name: 'Replace' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
await waitFor(() => expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT));
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
});
it('routes a plugin-use handoff from the Plugins page as the active driver and submits it as the run driver', 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('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(1, 'example-web-prototype')}
/>,
);
// "Use" now routes the picked plugin as the active driver (so its own
// pipeline + context apply on submit), not merely as background context.
// The active-plugin badge surfaces and the plugin is applied; a plain
// `use` leaves the draft empty (suppressPromptUpdate).
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-plugin')).toBeTruthy();
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
await screen.findByTestId('home-hero-input');
expect(homeHeroPromptValue()).toBe('');
// The user types their own brief over the empty draft, then submits — the
// routed plugin (not od-default) must drive the created run. Mirrors the
// P0 e2e "direct Use ... keeps the prompt freeform" flow.
await setPromptAndSettle('Use the selected starter as the driver');
await waitFor(() => {
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: 'Use the selected starter as the driver',
pluginId: 'example-web-prototype',
appliedPluginSnapshotId: 'snap-web-prototype',
})));
});
it('restores the Community template type while binding its exact plugin', 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('/api/plugins/example-web-prototype/apply')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(11, 'example-web-prototype', {
action: 'use',
chipId: 'prototype',
projectKind: 'prototype',
})}
/>,
);
await waitFor(() => {
// The lead chip cuts the title to eight code points ("Web Prot…").
expect(screen.getByTestId('home-hero-active-plugin').textContent).toContain('Web Prot…');
});
expect(JSON.parse(window.localStorage.getItem('open-design:home-composer:chip')!)).toEqual({
chipId: 'prototype',
pluginId: 'example-web-prototype',
projectKind: 'prototype',
// A "Use" hand-off is an explicit pick, and the draft is the only thing
// that survives a Home unmount — so it carries HOW the pick was made,
// not just what was picked. Restoring it as a bare type-chip binding
// would un-pin the plugin this hand-off exists to pin.
explicitPick: true,
});
});
it('routes free-form submits through the hidden default plugin without applying a visible chip', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [HIDDEN_DEFAULT_PLUGIN, DEFAULT_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
await setPromptAndSettle('Make a launch page for a robotics studio');
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(screen.queryByTestId('home-hero-active-plugin')).toBeNull();
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: 'Make a launch page for a robotics studio',
pluginId: 'od-default',
appliedPluginSnapshotId: null,
pluginInputs: { prompt: 'Make a launch page for a robotics studio' },
projectKind: 'other',
}));
});
it('falls back to od-new-generation when od-plugin-authoring is not registered yet', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [DEFAULT_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await clickHomeShortcut('create-plugin');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-new-generation/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {
artifactKind: 'OpenDesign plugin',
audience: 'OpenDesign plugin authors',
topic: 'packaging a reusable workflow as an OpenDesign plugin',
},
});
await waitFor(() => {
expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT);
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(screen.queryByRole('alert')).toBeNull();
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: PLUGIN_AUTHORING_PROMPT,
pluginId: 'od-new-generation',
appliedPluginSnapshotId: 'snap-default',
pluginInputs: {
artifactKind: 'OpenDesign plugin',
audience: 'OpenDesign plugin authors',
topic: 'packaging a reusable workflow as an OpenDesign plugin',
},
projectKind: 'other',
}));
});
it('hands the Home rail Prototype chip entirely to OD Next on submit', 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' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('prototype');
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
// The design-system picker is now a persistent control in the row below the
// composer (next to the working-directory picker), available for every
// product kind rather than gated on the prototype/deck footer.
expect(
screen.getByTestId('home-hero-design-system-trigger').textContent,
).toContain('Refly Design System');
// Fidelity is no longer a prototype footer control — the agent asks for it
// in discovery instead.
expect(screen.queryByTestId('home-hero-footer-option-fidelity')).toBeNull();
// The design-system footer pill is gone; the persistent picker replaces it.
expect(screen.queryByTestId('home-hero-footer-option-designSystem')).toBeNull();
expect(screen.getByTestId('home-hero-design-system-trigger')).toBeTruthy();
expect(homeHeroPromptValue()).toBe('');
expect(screen.getByTestId('home-hero-plugin-presets')).toBeTruthy();
// Inline `{{slot}}` prompt widgets were removed in the Lexical migration;
// these null checks now confirm the migrated editor never renders them.
expect(screen.queryByTestId('home-hero-prompt-slot-fidelity')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-artifactKind')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-designSystem')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-template')).toBeNull();
// The inline plugin inputs form was removed from the Home composer, so the
// non-footer inputs (artifactKind / audience / template) no longer render.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
await setPromptAndSettle('Build a pricing-page prototype.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'prototype',
appliedPluginSnapshotId: null,
pluginTitle: null,
taskKind: null,
skillId: null,
projectKind: 'prototype',
prompt: 'Build a pricing-page prototype.',
designSystemId: 'ds-refly',
projectMetadata: expect.objectContaining({
kind: 'prototype',
}),
})));
const [submitted] = onSubmit.mock.calls[0] as [Record<string, unknown>];
expect(submitted).not.toHaveProperty('pluginInputs');
expect(submitted).not.toHaveProperty('pluginSource');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
expect(screen.queryByRole('alert')).toBeNull();
});
it.each([
{
subtype: 'mobile',
prompt: 'Design a mobile checkout flow.',
metadata: {
kind: 'prototype',
platform: 'auto',
platformTargets: ['mobile-ios', 'mobile-android'],
},
},
{
subtype: 'wireframe',
prompt: 'Sketch a low-fidelity account setup flow.',
metadata: { kind: 'prototype', fidelity: 'wireframe' },
},
])('routes $subtype to the automatic OD Next Prototype route while preserving its project metadata', async ({
subtype,
prompt,
metadata,
}) => {
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-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('prototype');
await pickPrototypeScene(subtype);
await setPromptAndSettle(prompt);
fireEvent.click(screen.getByTestId('home-hero-submit'));
// A nested Prototype scene refines WHAT to build, never WHETHER the parent
// route applies: 移动应用 / 线框图 enter OD Next exactly as 原型 does, and
// their distinguishing metadata rides along into the bundle.
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'prototype',
appliedPluginSnapshotId: null,
pluginTitle: null,
taskKind: null,
skillId: null,
projectKind: 'prototype',
prompt,
projectMetadata: expect.objectContaining(metadata),
})));
const [submittedNested] = onSubmit.mock.calls[0] as [Record<string, unknown>];
// Exactly the scene's refinement over 原型's kind — no extra field, and the
// scene may not restate `kind` as something else.
expect(submittedNested.projectMetadata).toEqual(metadata);
expect(submittedNested).not.toHaveProperty('pluginInputs');
expect(submittedNested).not.toHaveProperty('pluginSource');
// Nothing is pinned, so there is no snapshot to resolve.
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
expect(screen.queryByRole('alert')).toBeNull();
});
it.each([
{
legacyChipId: 'mobile',
metadata: {
kind: 'prototype',
platform: 'auto',
platformTargets: ['mobile-ios', 'mobile-android'],
},
},
{ legacyChipId: 'wireframe', metadata: { kind: 'prototype', fidelity: 'wireframe' } },
])('selects the nested scene when a queued intent still names the retired $legacyChipId chip id', async ({
legacyChipId,
metadata,
}) => {
// `requestHomeChip` takes a bare string from another surface, so a caller
// that predates the creation-hierarchy move can still ask for a chip id
// that no longer exists. The hand-off must land on the scene it became
// instead of failing the catalog lookup and silently dropping the intent.
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-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
await clickHomeShortcut(legacyChipId);
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype');
expect(JSON.parse(window.localStorage.getItem('open-design:home-composer:chip') ?? '{}'))
.toMatchObject({ chipId: 'prototype', prototypeSubtypeId: legacyChipId });
});
await setPromptAndSettle('Lay out the onboarding screens.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const [submittedLegacy] = onSubmit.mock.calls[0] as [Record<string, unknown>];
expect(submittedLegacy).toMatchObject({
pluginId: null,
automaticStrategyTaskProfile: 'prototype',
projectKind: 'prototype',
});
expect(submittedLegacy.projectMetadata).toEqual(metadata);
});
it('keeps a Slide deck second-level scene on its own ppt route', async () => {
// Neighbour witness. Deck's second-level rail only narrows the example-card
// pool — it never reaches `onPickPrototypeSubtype` — so picking one must
// leave the deck route exactly where it was.
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [FACETED_DECK_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('deck');
// The deck scenes were example filters on the retired sub-type row; the
// route under test is the Slide deck type's own.
await setPromptAndSettle('Pitch our seed round to climate-tech investors.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'ppt',
appliedPluginSnapshotId: null,
projectKind: 'deck',
projectMetadata: expect.objectContaining({ kind: 'deck' }),
})));
const [submittedDeck] = onSubmit.mock.calls[0] as [Record<string, unknown>];
// A deck scene never stamps prototype refinements onto its metadata.
expect(submittedDeck.projectMetadata).not.toHaveProperty('fidelity');
expect(submittedDeck.projectMetadata).not.toHaveProperty('platformTargets');
expect(submittedDeck).not.toHaveProperty('exampleReference');
});
it('keeps Document prompt entry submittable even when od-new-generation has required inputs', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [DOCUMENT_NEW_GENERATION_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')) {
return new Response(JSON.stringify(DOCUMENT_NEW_GENERATION_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('document');
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Document');
});
await setPromptAndSettle('Write a crisp launch memo for the new analytics product.');
const submit = screen.getByTestId('home-hero-submit') as HTMLButtonElement;
await waitFor(() => expect(submit.disabled).toBe(false));
fireEvent.click(submit);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-new-generation/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {
artifactKind: 'document',
audience: 'readers',
topic: 'the user brief',
},
});
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'od-new-generation',
appliedPluginSnapshotId: 'snap-document-new-generation',
projectKind: 'other',
prompt: 'Write a crisp launch memo for the new analytics product.',
})));
expect(screen.queryByRole('alert')).toBeNull();
});
it('defaults to "No design system" (不指定) when the user has no personal default and submits a null designSystemId', 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' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
// A preset is offered (REFLY) but it is NOT the user's personal default, so
// the composer must default to "No design system" rather than a preset.
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId={null}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('prototype');
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype');
});
// Unset, the trigger is the palette glyph alone (per product: 不选择不显示
// 文案) — the field name lives on its accessible name, and the pill carries
// no visible label at all.
const unsetDsTrigger = screen.getByTestId('home-hero-design-system-trigger');
expect(unsetDsTrigger.getAttribute('aria-label')).toBe('Design system');
expect(unsetDsTrigger.textContent).toBe('');
await setPromptAndSettle('Build a pricing-page prototype.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'prototype',
projectKind: 'prototype',
designSystemId: null,
})));
expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('pluginInputs');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
expect(screen.queryByRole('alert')).toBeNull();
});
it('lets the user explicitly pick "No design system" to override a personal default and submit a null designSystemId', 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' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('prototype');
// The personal default pre-selects, as before.
await waitFor(() => {
expect(
screen.getByTestId('home-hero-design-system-trigger').textContent,
).toContain('Refly Design System');
});
// Open the shared design-system picker popover and pick the explicit
// "No design system" row.
fireEvent.click(screen.getByTestId('home-hero-design-system-trigger'));
const popover = await screen.findByTestId('project-ds-picker-popover');
const noneOption = await within(popover).findByText('No design system');
fireEvent.mouseDown(noneOption);
await waitFor(() => {
// With nothing selected the trigger drops back to the icon-only pill:
// no visible label, the field name on its accessible name instead.
const dsTrigger = screen.getByTestId('home-hero-design-system-trigger');
expect(dsTrigger.getAttribute('aria-label')).toBe('Design system');
expect(dsTrigger.textContent).toBe('');
});
await setPromptAndSettle('Build a pricing-page prototype.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'prototype',
designSystemId: null,
})));
expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('pluginInputs');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
expect(screen.queryByRole('alert')).toBeNull();
});
it('keeps an official example card under 原型 on the automatic OD Next route and sends its example reference', 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-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('prototype');
// The card itself is the single click-to-use affordance — clicking it
// directly seeds the composer input.
fireEvent.click(
(await screen.findAllByTestId('home-hero-plugin-preset')).find(
(item) => item.getAttribute('data-plugin-id') === 'example-web-prototype',
)!,
);
screen.getByTestId('home-hero-input');
await waitFor(() => {
expect(homeHeroPromptText()).toBe(
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.',
);
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype');
// Yielding the ROUTE to OD Next does not change the composer chrome: the
// example is still an explicit pick, so its own plugin badge (and clear ×)
// renders exactly as it did when the pick pinned a plugin.
expect(screen.getByTestId('home-hero-active-plugin').textContent).toContain('Web Prot…');
// The design-system picker is now the persistent control below the composer.
expect(
screen.getByTestId('home-hero-design-system-trigger').textContent,
).toContain('Refly Design System');
// Fidelity is no longer a prototype footer control (asked in discovery).
expect(screen.queryByTestId('home-hero-footer-option-fidelity')).toBeNull();
expect(screen.queryByTestId('home-hero-footer-option-designSystem')).toBeNull();
// Inline `{{slot}}` prompt widgets were removed in the Lexical migration.
expect(screen.queryByTestId('home-hero-prompt-slot-fidelity')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-artifactKind')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-designSystem')).toBeNull();
expect(screen.queryByTestId('home-hero-prompt-slot-template')).toBeNull();
// The inline plugin inputs form was removed from the Home composer; the
// preset card still seeds the prompt and keeps the chip's structured inputs
// in state (submitted below), but no inputs form renders.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
fireEvent.click(screen.getByTestId('home-hero-submit'));
// The example card names a LOOK, not a strategy: 原型 keeps its automatic
// OD Next route and the example's identity rides along as
// `exampleReference` for the daemon to re-resolve. No plugin pin, so no
// snapshot to resolve and no `/apply-local` roundtrip at all.
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'prototype',
appliedPluginSnapshotId: null,
pluginTitle: null,
taskKind: null,
skillId: null,
exampleReference: {
pluginId: 'example-web-prototype',
source: '/tmp/web-prototype',
},
projectKind: 'prototype',
prompt: 'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.',
designSystemId: 'ds-refly',
projectMetadata: expect.objectContaining({
kind: 'prototype',
}),
})));
const [submittedPreset] = onSubmit.mock.calls[0] as [Record<string, unknown>];
expect(submittedPreset).not.toHaveProperty('pluginInputs');
expect(submittedPreset).not.toHaveProperty('pluginSource');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
});
it('keeps a deck example card on the automatic ppt route and sends its example reference', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [SIMPLE_DECK_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('deck');
fireEvent.click(
(await screen.findAllByTestId('home-hero-plugin-preset')).find(
(item) => item.getAttribute('data-plugin-id') === 'example-simple-deck',
)!,
);
await waitFor(() => expect(homeHeroPromptText().length).toBeGreaterThan(0));
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'ppt',
appliedPluginSnapshotId: null,
pluginTitle: null,
taskKind: null,
skillId: null,
exampleReference: {
pluginId: 'example-simple-deck',
source: '/tmp/simple-deck',
},
projectKind: 'deck',
})));
const [submittedDeck] = onSubmit.mock.calls[0] as [Record<string, unknown>];
expect(submittedDeck).not.toHaveProperty('pluginInputs');
expect(submittedDeck).not.toHaveProperty('pluginSource');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
});
it.each([
{ subtype: 'wireframe', metadata: { kind: 'prototype', fidelity: 'wireframe' } },
{
subtype: 'mobile',
metadata: {
kind: 'prototype',
platform: 'auto',
platformTargets: ['mobile-ios', 'mobile-android'],
},
},
])('carries an example card under the $subtype Prototype scene on the automatic OD Next route', async ({
subtype,
metadata,
}) => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [FACETED_WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('prototype');
await pickPrototypeScene(subtype);
fireEvent.click(
(await screen.findAllByTestId('home-hero-plugin-preset')).find(
(item) => item.getAttribute('data-plugin-id') === 'example-web-prototype',
)!,
);
await waitFor(() => expect(homeHeroPromptText().length).toBeGreaterThan(0));
fireEvent.click(screen.getByTestId('home-hero-submit'));
// 移动应用 / 线框图 ride the parent 原型 route, so the example card has an
// automatic profile to hand its routing back to: it names a LOOK, not a
// strategy. No plugin pin, so no snapshot and no `/apply-local` roundtrip.
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'prototype',
appliedPluginSnapshotId: null,
pluginTitle: null,
taskKind: null,
skillId: null,
exampleReference: {
pluginId: 'example-web-prototype',
source: '/tmp/web-prototype',
},
projectKind: 'prototype',
projectMetadata: expect.objectContaining(metadata),
})));
const [submittedScene] = onSubmit.mock.calls[0] as [Record<string, unknown>];
expect(submittedScene).not.toHaveProperty('pluginInputs');
expect(submittedScene).not.toHaveProperty('pluginSource');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
});
it('still pins the plugin for a Community "Use" pick made under the same OD Next task type', 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-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(21, 'example-web-prototype', {
action: 'use',
chipId: 'prototype',
projectKind: 'prototype',
})}
/>,
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
await setPromptAndSettle('Use the Community pick as the driver');
fireEvent.click(screen.getByTestId('home-hero-submit'));
// The SAME record reaches Home's example rail and the Community grid. The
// call site — not the record — decides: a Community / details "Use" is a
// strategy choice and keeps pinning its plugin even under 原型.
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-web-prototype',
appliedPluginSnapshotId: 'snap-web-prototype',
pluginSource: '/tmp/web-prototype',
})));
const [submittedUse] = onSubmit.mock.calls[0] as [Record<string, unknown>];
expect(submittedUse).not.toHaveProperty('exampleReference');
expect(submittedUse).not.toHaveProperty('automaticStrategyTaskProfile');
expect(submittedUse).toHaveProperty('pluginInputs');
});
it('binds the picked preset plugin on submit while preserving the chip metadata', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({
plugins: [LIVE_ARTIFACT_PLUGIN, LIVE_ARTIFACT_IMAGE_TEMPLATE_PLUGIN],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(LIVE_ARTIFACT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('live-artifact');
await waitFor(() => {
expect(screen.getAllByTestId('home-hero-plugin-preset').length).toBeGreaterThan(0);
});
const liveArtifactTemplatePreset = screen.getAllByTestId('home-hero-plugin-preset')
.find((item) => item.getAttribute('data-plugin-id') === LIVE_ARTIFACT_IMAGE_TEMPLATE_PLUGIN.id);
if (!liveArtifactTemplatePreset) {
throw new Error('expected live artifact image template preset to render');
}
// The card itself is the single click-to-use affordance — clicking it
// directly seeds the composer.
fireEvent.click(liveArtifactTemplatePreset);
screen.getByTestId('home-hero-input');
// The composer seed prefers the curated description over the query head
// (the query is generator-facing; it still reaches the agent as plugin
// context on apply).
await waitFor(() => {
expect(homeHeroPromptText()).toBe('Create a live Notion dashboard artifact.');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/apply-local')
))).toBe(false);
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Live artifact');
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
fireEvent.click(screen.getByTestId('home-hero-submit'));
// Picking a preset binds the preset's OWN plugin (so its SKILL.md /
// example.html become generation context and the output recreates that
// reference), while the live-artifact chip's project kind + metadata are
// carried forward. Submit resolves the snapshot for the preset plugin.
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/image-template-notion-team-dashboard-live-artifact/apply-local',
expect.anything(),
));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'image-template-notion-team-dashboard-live-artifact',
appliedPluginSnapshotId: 'snap-live-artifact',
projectKind: 'prototype',
projectMetadata: expect.objectContaining({
kind: 'prototype',
intent: 'live-artifact',
fidelity: 'high-fidelity',
}),
prompt: 'Create a live Notion dashboard artifact.',
})));
});
it('binds the Home rail Live artifact chip with live-artifact metadata and applies it on submit', 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, LIVE_ARTIFACT_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')) {
return new Response(JSON.stringify(LIVE_ARTIFACT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('live-artifact');
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Live artifact');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')
))).toBe(false);
await setPromptAndSettle('Build a refreshable Stripe revenue dashboard.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-live-artifact/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {},
});
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-live-artifact',
appliedPluginSnapshotId: 'snap-live-artifact',
projectKind: 'prototype',
projectMetadata: expect.objectContaining({
kind: 'prototype',
intent: 'live-artifact',
fidelity: 'high-fidelity',
}),
prompt: 'Build a refreshable Stripe revenue dashboard.',
})));
expect(screen.queryByRole('alert')).toBeNull();
});
it('binds the deck chip and keeps only the design-system picker in the footer', async () => {
// Slide count + speaker-notes footer controls were removed from the deck
// composer; the agent asks for them in the first-turn discovery flow. The
// deck footer now mirrors the prototype footer — design system only.
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [SIMPLE_DECK_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('deck');
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Slide deck');
});
expect(screen.queryByTestId('home-hero-footer-option-speakerNotes')).toBeNull();
expect(screen.queryByTestId('home-hero-footer-option-slideCount')).toBeNull();
expect(screen.queryByTestId('home-hero-footer-option-designSystem')).toBeNull();
// The design-system picker is the persistent control below the composer.
expect(screen.getByTestId('home-hero-design-system-trigger')).toBeTruthy();
await setPromptAndSettle('Create an investor deck for a local-first design tool.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: null,
automaticStrategyTaskProfile: 'ppt',
appliedPluginSnapshotId: null,
pluginTitle: null,
taskKind: null,
skillId: null,
projectKind: 'deck',
projectMetadata: expect.objectContaining({
kind: 'deck',
}),
})));
expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('pluginInputs');
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply-local'))).toBe(false);
});
it('switches output-type chips without replacing an existing prompt', 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-local')) {
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
await setPromptAndSettle('Keep my current brief');
await pickHomeTemplate('prototype');
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
expect(homeHeroPromptText()).toBe('Keep my current brief');
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
});
it('lets selected chips seed the hero through preset cards', 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, SIMPLE_DECK_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')) {
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={() => undefined}
onOpenProject={() => undefined}
/>,
);
await pickHomeTemplate('deck');
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Slide deck');
});
expect(screen.getByTestId('home-hero-plugin-presets')).toBeTruthy();
expect(screen.getByTestId('home-hero-plugin-presets').textContent).toContain('Simple Deck');
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')
))).toBe(false);
await waitFor(() => {
expect(homeHeroPromptText()).toBe(
'Create a pitch deck for decision makers about the user brief with 10-15 pages. Speaker notes: include speaker notes. Use the active project design system.',
);
});
await pickHomeTemplate('prototype');
await waitFor(() => {
expect(screen.getByTestId('home-hero-plugin-presets')).toBeTruthy();
});
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
await waitFor(() => {
expect(homeHeroPromptText()).toBe(
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.',
);
});
});
it('appends a plugin-use query handoff without replacing an existing prompt', 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('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
await setPromptAndSettle('Keep my current brief');
rerender(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(2, 'example-web-prototype', {
action: 'use-with-query',
})}
/>,
);
const expectedPrompt = [
'Keep my current brief',
'',
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.',
].join('\n');
// `use-with-query` must APPEND the plugin query to the user's existing
// draft, never replace it — this is the regression the reviewer flagged.
await waitFor(() => {
expect(homeHeroPromptText()).toBe(expectedPrompt);
});
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
// The plugin is now routed as the active driver (active-plugin badge),
// and applied so its pipeline/context bind on submit.
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-plugin')).toBeTruthy();
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
});
it('seeds the rendered query on use-with-query and writes placeholder edits back into inputs', async () => {
// For a plugin whose query is already human-readable, use-with-query seeds
// the rendered query itself. Because the seed came from the query (not a
// description/meta-instruction fallback), the raw `{{...}}` template is kept
// so editing a hydrated value in the composer flows back into pluginInputs
// and submit resolves the snapshot from what the user sees.
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('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
// Empty draft + use-with-query seeds the example-preset text into the editor.
rerender(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(3, 'example-web-prototype', {
action: 'use-with-query',
})}
/>,
);
const seed =
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.';
await waitFor(() => expect(homeHeroPromptText()).toBe(seed));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
// The user edits the seeded audience; the placeholder edit flows back into
// the submitted pluginInputs (not the stale applied default).
const edited = seed.replace('product evaluators', 'enterprise architects');
await setPromptAndSettle(edited);
await waitFor(() => {
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-web-prototype',
pluginInputs: expect.objectContaining({ audience: 'enterprise architects' }),
})));
});
it('extracts a placeholder edit even after the use-with-query draft prefix is also edited', async () => {
// The "tweak a preset before running" case: with an existing draft,
// use-with-query appends the rendered query; the user then edits BOTH the
// prefix and a hydrated placeholder. `queryTemplateAllowsPrefix` matches the
// query as a suffix after any prefix, so the placeholder edit still reaches
// pluginInputs and submit resolves the snapshot from the visible prompt.
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('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
await setPromptAndSettle('Keep my current brief');
rerender(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(4, 'example-web-prototype', {
action: 'use-with-query',
})}
/>,
);
const query =
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.';
const appended = `Keep my current brief\n\n${query}`;
await waitFor(() => expect(homeHeroPromptText()).toBe(appended));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
const edited = appended
.replace('Keep my current brief', 'Rewritten brief for the board')
.replace('product evaluators', 'enterprise architects');
await setPromptAndSettle(edited);
await waitFor(() => {
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-web-prototype',
pluginInputs: expect.objectContaining({ audience: 'enterprise architects' }),
})));
});
it('extracts a placeholder edit after the user prepends an intro to an empty-draft use-with-query seed', async () => {
// The empty-draft → add-prefix → edit-placeholder case: the seed lands in an
// empty composer, then the user prepends an intro AND edits a hydrated value.
// queryTemplateAllowsPrefix must stay on (we have a query template) so the
// extractor matches the query as a suffix after the freshly-added prefix and
// the placeholder edit still flows into pluginInputs.
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('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
// Empty draft + use-with-query seeds the rendered query into the editor.
rerender(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(6, 'example-web-prototype', {
action: 'use-with-query',
})}
/>,
);
const seed =
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.';
await waitFor(() => expect(homeHeroPromptText()).toBe(seed));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
// The user now PREPENDS an intro above the seed and edits the audience.
const edited = `My intro for the board\n\n${seed.replace('product evaluators', 'enterprise architects')}`;
await setPromptAndSettle(edited);
await waitFor(() => {
expect((screen.getByTestId('home-hero-submit') as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-web-prototype',
pluginInputs: expect.objectContaining({ audience: 'enterprise architects' }),
})));
});
it('seeds the plugin description, not the raw meta-instruction query, on use-with-query', async () => {
// The plugin's useCase.query is a generator-facing meta-instruction
// ("follow the en field verbatim; start from example.html"). The Home
// example-prompt cards surface the description instead; the detail modal's
// prompt-loading "Use" (use-with-query) must do the same rather than
// dumping the meta-instruction into the composer.
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [META_INSTRUCTION_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-meta-landing/apply-local')) {
return new Response(JSON.stringify(META_INSTRUCTION_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await screen.findByTestId('home-hero-input');
rerender(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
promptHandoff={createPluginUseHandoff(5, 'example-meta-landing', {
action: 'use-with-query',
})}
/>,
);
await waitFor(() => expect(homeHeroPromptText()).toBe('Cinematic parallax landing page.'));
expect(homeHeroPromptText()).not.toContain('verbatim');
expect(homeHeroPromptText()).not.toContain('example.html');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-meta-landing/apply-local',
expect.anything(),
));
});
it('binds od-plugin-authoring before submitting the rail create-plugin prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN, WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await clickHomeShortcut('create-plugin');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
await waitFor(() => {
const badge = screen.getByTestId('home-hero-active-plugin');
// Eight code points, then an ellipsis — the lead chip's truncation.
expect(badge.textContent).toContain('Create p…');
expect(badge.textContent).not.toContain('Plugin authoring');
});
const input = screen.getByTestId('home-hero-input');
const inputCard = input.closest('.home-hero__input-card') as HTMLElement | null;
expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT);
expect(inputCard?.classList.contains('home-hero__input-card--compact-authoring')).toBe(true);
expect(inputCard?.style.getPropertyValue('--home-hero-prompt-max-height')).toBe('132px');
fireEvent.click(await screen.findByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: PLUGIN_AUTHORING_PROMPT,
pluginId: 'od-plugin-authoring',
appliedPluginSnapshotId: 'snap-authoring',
pluginInputs: { pluginGoal: PLUGIN_AUTHORING_DEFAULT_GOAL },
projectKind: 'other',
}));
});
it('keeps the authoring goal input linked to the prompt and submit payload', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN, WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await clickHomeShortcut('create-plugin');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
const rewrittenGoal = 'catalog internal research notes into a reusable knowledge workflow';
screen.getByTestId('home-hero-input');
await setPromptAndSettle(
homeHeroPromptText().replace(PLUGIN_AUTHORING_DEFAULT_GOAL, rewrittenGoal),
);
await waitFor(() => {
expect(homeHeroPromptText()).toContain(rewrittenGoal);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: expect.stringContaining(rewrittenGoal),
pluginId: 'od-plugin-authoring',
pluginInputs: {
pluginGoal: rewrittenGoal,
},
})));
});
it('does not submit the create-plugin prompt before the authoring scenario is applied', async () => {
let resolveApply: (response: Response) => void = () => undefined;
const applyResponse = new Promise<Response>((resolve) => {
resolveApply = resolve;
});
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN, WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply-local')) {
return applyResponse;
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
/>,
);
await clickHomeShortcut('create-plugin');
const input = screen.getByTestId('home-hero-input');
const inputCard = input.closest('.home-hero__input-card') as HTMLElement | null;
expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT);
expect(inputCard?.classList.contains('home-hero__input-card--compact-authoring')).toBe(true);
expect(inputCard?.style.getPropertyValue('--home-hero-prompt-max-height')).toBe('132px');
fireEvent.click(await screen.findByTestId('home-hero-submit'));
expect(onSubmit).not.toHaveBeenCalled();
resolveApply(new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
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({
pluginId: 'od-plugin-authoring',
appliedPluginSnapshotId: 'snap-authoring',
}));
});
});
// An empty Lexical editor renders `<p><br></p>` (a placeholder break node), so
// the DOM serializer in `homeHeroPromptText()` reads that lone `<br>` back as
// `'\n'`. The editor's real text is empty — `.textContent` is `''` — so this
// reads the empty case precisely without weakening the genuine-content path.
function homeHeroPromptValue(): string {
const text = homeHeroPromptText();
if (text === '\n' && (screen.getByTestId('home-hero-input').textContent ?? '') === '') {
return '';
}
return text;
}
// Replace the Lexical editor's text the way a user edit would, then let the
// editor's OnChange → host `onPromptChange` React state update flush a
// microtask (mirrors lexical-composer's `typeAndSettle`) so flows that submit
// right after editing read the latest draft.
async function setPromptAndSettle(value: string): Promise<void> {
setHomeHeroPrompt(value);
await act(async () => {
await Promise.resolve();
});
}
// The hero no longer renders a second-level scene row; a Prototype scene is
// reached the way other surfaces hand one off — a queued chip intent naming the
// retired top-level id, which HomeView folds onto 原型 + that scene.
async function pickPrototypeScene(scene: string) {
await act(async () => {
requestHomeChip(scene);
});
await waitFor(() => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype');
expect(JSON.parse(window.localStorage.getItem('open-design:home-composer:chip') ?? '{}'))
.toMatchObject({ chipId: 'prototype', prototypeSubtypeId: scene });
});
}
// The migrate shortcuts (plugin authoring / Figma / template) left the Home
// composer with the rail. Their surviving producers — the Extensions tab and
// the Design systems tab — dispatch the same chip through `requestHomeChip`,
// which is the entry this drives.
async function clickHomeShortcut(id: string) {
await act(async () => {
requestHomeChip(id);
await Promise.resolve();
});
}