mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
1478 lines
51 KiB
TypeScript
1478 lines
51 KiB
TypeScript
// @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<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')) {
|
|
return applyResponse;
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
stubAnimationFrame();
|
|
|
|
const { rerender } = render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => 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(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => 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(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => 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<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();
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => 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<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}
|
|
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<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')) {
|
|
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}
|
|
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<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return new Response(JSON.stringify(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}
|
|
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<typeof fetch>(async (url) => {
|
|
if (typeof url === 'string' && url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (typeof url === 'string' && url.includes('/apply')) {
|
|
return new Response(JSON.stringify(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}
|
|
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<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')) {
|
|
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}
|
|
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<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')) {
|
|
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}
|
|
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<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('/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(
|
|
<HomeView
|
|
projects={[]}
|
|
designSystems={[REFLY_DESIGN_SYSTEM]}
|
|
defaultDesignSystemId="ds-refly"
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => 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<typeof fetch>(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(
|
|
<I18nProvider initial="zh-CN">
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
|
|
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<string, unknown> } | undefined;
|
|
expect(typeof submitted?.pluginInputs?.duration).toBe('number');
|
|
});
|
|
|
|
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')) {
|
|
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}
|
|
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<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')) {
|
|
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(
|
|
<HomeView
|
|
projects={[]}
|
|
designSystems={[REFLY_DESIGN_SYSTEM]}
|
|
defaultDesignSystemId="ds-refly"
|
|
onSubmit={() => 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<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 { rerender } = render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => undefined}
|
|
onOpenProject={() => undefined}
|
|
onViewAllProjects={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
const input = await screen.findByTestId('home-hero-input');
|
|
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
|
|
|
|
rerender(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={() => 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<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')) {
|
|
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}
|
|
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<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')) {
|
|
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}
|
|
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<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')) {
|
|
return applyResponse;
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
stubAnimationFrame();
|
|
const onSubmit = vi.fn();
|
|
|
|
render(
|
|
<HomeView
|
|
projects={[]}
|
|
onSubmit={onSubmit}
|
|
onOpenProject={() => 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}`));
|
|
}
|