// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { HomeView } from '../../src/components/HomeView'; import { I18nProvider } from '../../src/i18n'; import { createPluginAuthoringHandoff, createPluginUseHandoff, PLUGIN_AUTHORING_DEFAULT_GOAL, PLUGIN_AUTHORING_PROMPT, } from '../../src/components/home-hero/plugin-authoring'; 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 Open Design 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 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, }, }, }; // The Prototype chip binds to the bundled `example-web-prototype` // plugin (which ships its own seed + layouts + checklist) instead of // the generic od-new-generation router. Mirror that here so the // chip-applies test can find a matching plugin record and the apply // call resolves to the new id. 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', }, ], }, }, }; 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 Open Design artifacts.', od: { kind: 'scenario', taskKind: 'new-generation', mode: 'prototype', scenario: 'live', useCase: { query: 'Create refreshable, auditable Open Design 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: 'Open Design plugin', audience: 'Open Design plugin authors', topic: 'packaging a reusable workflow as an Open Design 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 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', }, }, }; 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); }); } const NUMBER_DURATION_PLUGIN = { ...DEFAULT_PLUGIN, id: 'example-simple-deck', title: 'Duration parser test', source: '/tmp/duration-parser', fsPath: '/tmp/duration-parser', manifest: { ...DEFAULT_PLUGIN.manifest, name: 'example-simple-deck', title: 'Duration parser test', description: 'Exercise localized number parsing.', od: { kind: 'scenario', taskKind: 'new-generation', useCase: { query: 'Create a {{duration}} HyperFrames launch composition for {{product}} with {{motionStyle}} motion.', }, inputs: [ { name: 'duration', label: 'Duration', type: 'number', required: true, default: 10, }, { name: 'product', label: 'Product', type: 'string', required: true, default: 'AI note app', }, { name: 'motionStyle', label: 'Motion style', type: 'string', required: true, default: 'minimal reveal', }, ], }, }, }; const NUMBER_DURATION_APPLY_RESULT = { ...AUTHORING_APPLY_RESULT, query: NUMBER_DURATION_PLUGIN.manifest.od.useCase.query, inputs: NUMBER_DURATION_PLUGIN.manifest.od.inputs, appliedPlugin: { ...AUTHORING_APPLY_RESULT.appliedPlugin, snapshotId: 'snap-duration-parser', pluginId: 'example-simple-deck', inputs: { duration: 10, product: 'AI note app', motionStyle: 'minimal reveal', }, }, }; describe('HomeView prompt handoff', () => { afterEach(() => { vi.unstubAllGlobals(); cleanup(); }); it('consumes a plugin authoring handoff once and focuses the textarea', async () => { let resolveApply: (response: Response) => void = () => undefined; const applyResponse = new Promise((resolve) => { resolveApply = resolve; }); const fetchMock = 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')) { return applyResponse; } throw new Error(`unexpected fetch ${url}`); }); vi.stubGlobal('fetch', fetchMock); stubAnimationFrame(); const { rerender } = render( undefined} onOpenProject={() => undefined} onViewAllProjects={() => undefined} promptHandoff={createPluginAuthoringHandoff(1)} />, ); const input = await screen.findByTestId('home-hero-input'); await waitFor(() => { expect((input as HTMLTextAreaElement).value).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', 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); }); fireEvent.change(input, { target: { value: 'User edited prompt' } }); rerender( undefined} onOpenProject={() => undefined} onViewAllProjects={() => undefined} promptHandoff={createPluginAuthoringHandoff(1)} />, ); expect((input as HTMLTextAreaElement).value).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')) { return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), { status: 200, headers: { 'content-type': 'application/json' }, }); } throw new Error(`unexpected fetch ${url}`); })); stubAnimationFrame(); render( undefined} onOpenProject={() => undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); await clickHomeShortcut('create-plugin'); const input = await screen.findByTestId('home-hero-input'); await waitFor(() => { expect((input as HTMLTextAreaElement).value).toBe(PLUGIN_AUTHORING_PROMPT); expect(document.activeElement).toBe(input); }); expect(screen.queryByRole('alert')).toBeNull(); }); it('adds a plugin-use handoff from the Plugins page as context', async () => { const fetchMock = vi.fn(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(); render( undefined} onOpenProject={() => undefined} onViewAllProjects={() => undefined} promptHandoff={createPluginUseHandoff(1, 'example-web-prototype')} />, ); await waitFor(() => { expect(screen.getByTestId('home-hero-context-plugin-example-web-prototype')).toBeTruthy(); }); expect((await screen.findByTestId('home-hero-input') as HTMLTextAreaElement).value) .toBe(''); expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false); }); it('routes free-form submits through the hidden default plugin without applying a visible chip', async () => { const fetchMock = vi.fn(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( undefined} onViewAllProjects={() => undefined} />, ); const input = await screen.findByTestId('home-hero-input'); fireEvent.change(input, { target: { value: '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(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')) { 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( undefined} onViewAllProjects={() => undefined} />, ); await clickHomeShortcut('create-plugin'); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/od-new-generation/apply', expect.anything(), )); const applyCall = fetchMock.mock.calls.find(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply') )); expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({ inputs: { artifactKind: 'Open Design plugin', audience: 'Open Design plugin authors', topic: 'packaging a reusable workflow as an Open Design plugin', }, }); await waitFor(() => { expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value) .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: 'Open Design plugin', audience: 'Open Design plugin authors', topic: 'packaging a reusable workflow as an Open Design plugin', }, projectKind: 'other', })); }); it('binds the Home rail Prototype chip locally and applies it on submit', async () => { const fetchMock = vi.fn(async (url) => { if (typeof url === 'string' && url === '/api/plugins') { return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (typeof url === 'string' && url.includes('/apply')) { return new Response(JSON.stringify(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( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); await waitFor(() => { expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Prototype'); }); expect(fetchMock.mock.calls.some(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply') ))).toBe(false); expect( screen.getByTestId('home-hero-footer-option-designSystem').textContent, ).toContain('Refly Design System'); expect(screen.getByTestId('home-hero-footer-option-fidelity')).toBeTruthy(); expect(screen.getByTestId('home-hero-footer-option-designSystem')).toBeTruthy(); expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe(''); expect(screen.getByTestId('home-hero-plugin-presets')).toBeTruthy(); 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(); expect(screen.queryByTestId('plugin-inputs-form')).toBeNull(); fireEvent.change(screen.getByTestId('home-hero-input'), { target: { value: 'Build a pricing-page prototype.' }, }); fireEvent.click(screen.getByTestId('home-hero-submit')); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/example-web-prototype/apply', expect.anything(), )); const applyCall = fetchMock.mock.calls.find(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply') )); expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({ inputs: { artifactKind: 'web prototype', fidelity: 'high-fidelity', audience: 'product evaluators', designSystem: 'Refly Design System', template: 'the bundled web prototype seed', }, }); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ pluginId: 'example-web-prototype', projectKind: 'prototype', prompt: 'Build a pricing-page prototype.', designSystemId: 'ds-refly', projectMetadata: expect.objectContaining({ kind: 'prototype', fidelity: 'high-fidelity', }), }))); expect(screen.queryByRole('alert')).toBeNull(); }); it('uses example preset cards as plain-text prompt fillers while preserving selected chip inputs', async () => { const fetchMock = vi.fn(async (url) => { if (typeof url === 'string' && url === '/api/plugins') { return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (typeof url === 'string' && url.includes('/apply')) { return new Response(JSON.stringify(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( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); fireEvent.click(await screen.findByTestId('home-hero-plugin-preset')); const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement; await waitFor(() => { expect(input.value).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') ))).toBe(false); expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Prototype'); expect( screen.getByTestId('home-hero-footer-option-designSystem').textContent, ).toContain('Refly Design System'); expect(screen.getByTestId('home-hero-footer-option-fidelity').textContent).toContain('High fidelity'); expect(screen.queryByTestId('plugin-inputs-form')).toBeNull(); 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(); fireEvent.click(screen.getByTestId('home-hero-submit')); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/example-web-prototype/apply', expect.anything(), )); const applyCall = fetchMock.mock.calls.find(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply') )); expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({ inputs: { artifactKind: 'web prototype', fidelity: 'high-fidelity', audience: 'product evaluators', designSystem: 'Refly Design System', template: 'the bundled web prototype seed', }, }); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ pluginId: 'example-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', fidelity: 'high-fidelity', }), }))); }); it('submits live-artifact example presets with chip metadata while keeping them plain-text only', async () => { const fetchMock = vi.fn(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')) { 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( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-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'); } fireEvent.click(liveArtifactTemplatePreset); const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement; await waitFor(() => { expect(input.value).toBe('Create a refreshable Notion dashboard live artifact.'); }); expect(fetchMock.mock.calls.some(([url]) => ( typeof url === 'string' && url.includes('/apply') ))).toBe(false); expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Live artifact'); expect(screen.queryByTestId('plugin-inputs-form')).toBeNull(); fireEvent.click(screen.getByTestId('home-hero-submit')); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/example-live-artifact/apply', expect.anything(), )); 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: 'Create a refreshable Notion dashboard live artifact.', }))); }); it('binds the Home rail Live artifact chip with live-artifact metadata and applies it on submit', async () => { const fetchMock = vi.fn(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')) { 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( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-live-artifact')); await waitFor(() => { expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Live artifact'); }); expect(fetchMock.mock.calls.some(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply') ))).toBe(false); fireEvent.change(screen.getByTestId('home-hero-input'), { target: { value: 'Build a refreshable Stripe revenue dashboard.' }, }); fireEvent.click(screen.getByTestId('home-hero-submit')); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/example-live-artifact/apply', expect.anything(), )); const applyCall = fetchMock.mock.calls.find(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply') )); 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('exposes deck page ranges beside speaker notes and submits the selected range', async () => { const fetchMock = vi.fn(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('/api/plugins/example-simple-deck/apply')) { 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( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-deck')); await waitFor(() => { expect(screen.getByTestId('home-hero-footer-option-speakerNotes')).toBeTruthy(); }); expect(screen.getByTestId('home-hero-footer-option-slideCount').textContent).toContain('10-15 pages'); fireEvent.click(screen.getByTestId('home-hero-footer-option-slideCount')); fireEvent.click(await screen.findByRole('option', { name: '15-20 pages' })); expect(screen.getByTestId('home-hero-footer-option-slideCount').textContent).toContain('15-20 pages'); fireEvent.change(screen.getByTestId('home-hero-input'), { target: { value: 'Create an investor deck for a local-first design tool.' }, }); fireEvent.click(screen.getByTestId('home-hero-submit')); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/example-simple-deck/apply', expect.anything(), )); const applyCall = fetchMock.mock.calls.find(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply') )); expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({ inputs: { slideCount: '15-20 pages', speakerNotes: 'include speaker notes', designSystem: 'Refly Design System', }, }); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ pluginId: 'example-simple-deck', pluginInputs: expect.objectContaining({ slideCount: '15-20 pages', }), projectKind: 'deck', projectMetadata: expect.objectContaining({ kind: 'deck', slideCount: '15-20 pages', speakerNotes: true, }), }))); }); it('keeps localized number inputs numeric when a zh-CN prompt is edited', async () => { const fetchMock = vi.fn(async (url) => { if (typeof url === 'string' && url === '/api/plugins') { return new Response(JSON.stringify({ plugins: [NUMBER_DURATION_PLUGIN] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (typeof url === 'string' && url === '/api/mcp/servers') { return new Response(JSON.stringify({ servers: [], templates: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (typeof url === 'string' && url.includes('/apply')) { return new Response(JSON.stringify(NUMBER_DURATION_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( undefined} onViewAllProjects={() => undefined} /> , ); fireEvent.click(await screen.findByTestId('home-hero-rail-deck')); await waitFor(() => { expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('幻灯片'); }); const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '为 AI 客服平台 创建一个 10 秒 的 HyperFrames 发布动效,运动风格为 minimal reveal。', }, }); fireEvent.click(screen.getByTestId('home-hero-submit')); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/example-simple-deck/apply', expect.anything(), ); expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ pluginId: 'example-simple-deck', pluginInputs: expect.objectContaining({ duration: 10, product: 'AI 客服平台', }), })); }); const submitted = onSubmit.mock.calls[0]?.[0] as { pluginInputs?: Record } | undefined; expect(typeof submitted?.pluginInputs?.duration).toBe('number'); }); it('switches output-type chips without replacing an existing prompt', async () => { const fetchMock = vi.fn(async (url) => { if (typeof url === 'string' && url === '/api/plugins') { return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (typeof url === 'string' && url.includes('/apply')) { return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), { status: 200, headers: { 'content-type': 'application/json' }, }); } throw new Error(`unexpected fetch ${url}`); }); vi.stubGlobal('fetch', fetchMock); stubAnimationFrame(); render( undefined} onOpenProject={() => undefined} onViewAllProjects={() => undefined} />, ); const input = await screen.findByTestId('home-hero-input'); fireEvent.change(input, { target: { value: 'Keep my current brief' } }); await clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); await waitFor(() => { expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Prototype'); }); expect(fetchMock.mock.calls.some(([url]) => ( typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply') ))).toBe(false); expect((input as HTMLTextAreaElement).value).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(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')) { 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')) { 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( undefined} onOpenProject={() => undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-deck')); await waitFor(() => { expect(screen.getByTestId('home-hero-active-type-chip').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') ))).toBe(false); expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).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 clearActiveTypeChip(); fireEvent.click(await screen.findByTestId('home-hero-rail-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') ))).toBe(false); expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).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(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 { rerender } = render( undefined} onOpenProject={() => undefined} onViewAllProjects={() => undefined} />, ); const input = await screen.findByTestId('home-hero-input'); fireEvent.change(input, { target: { value: 'Keep my current brief' } }); rerender( undefined} onOpenProject={() => undefined} onViewAllProjects={() => 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'); await waitFor(() => { expect((input as HTMLTextAreaElement).value).toBe(expectedPrompt); expect((input as HTMLTextAreaElement).selectionStart).toBe(expectedPrompt.length); expect((input as HTMLTextAreaElement).selectionEnd).toBe(expectedPrompt.length); }); expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull(); expect(screen.getByTestId('home-hero-context-plugin-example-web-prototype')).toBeTruthy(); expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false); }); it('binds od-plugin-authoring before submitting the rail create-plugin prompt', async () => { const fetchMock = 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('/apply')) { 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( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); await clickHomeShortcut('create-plugin'); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/od-plugin-authoring/apply', expect.anything(), )); await waitFor(() => { const badge = screen.getByTestId('home-hero-active-plugin'); expect(badge.textContent).toContain('Create plugin'); expect(badge.textContent).not.toContain('Plugin authoring'); }); const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement; const inputCard = input.closest('.home-hero__input-card') as HTMLElement | null; expect(input.value).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(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')) { 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( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); await clickHomeShortcut('create-plugin'); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/plugins/od-plugin-authoring/apply', expect.anything(), )); const rewrittenGoal = 'catalog internal research notes into a reusable knowledge workflow'; const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: input.value.replace( PLUGIN_AUTHORING_DEFAULT_GOAL, rewrittenGoal, ), }, }); await waitFor(() => { expect(input.value).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((resolve) => { resolveApply = resolve; }); const fetchMock = 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('/apply')) { return applyResponse; } throw new Error(`unexpected fetch ${url}`); }); vi.stubGlobal('fetch', fetchMock); stubAnimationFrame(); const onSubmit = vi.fn(); render( undefined} onViewAllProjects={() => undefined} />, ); await clearActiveTypeChip(); await clickHomeShortcut('create-plugin'); const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement; const inputCard = input.closest('.home-hero__input-card') as HTMLElement | null; expect(input.value).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', })); }); }); async function clearActiveTypeChip() { const chip = screen.queryByTestId('home-hero-active-type-chip'); if (chip) fireEvent.click(chip); } async function clickHomeShortcut(id: string) { const trigger = await screen.findByTestId('home-hero-shortcuts-trigger'); await waitFor(() => expect((trigger as HTMLButtonElement).disabled).toBe(false)); fireEvent.click(trigger); fireEvent.click(await screen.findByTestId(`home-hero-rail-${id}`)); }