Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

480 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
NextStepActions,
PROJECT_CONTINUE_PROMPT,
PROJECT_GENERATE_ARTIFACT_PROMPT,
} from '../../src/components/NextStepActions';
import { I18nProvider } from '../../src/i18n';
import { en } from '../../src/i18n/locales/en';
import type { Locale } from '../../src/i18n/types';
import type { SkillSummary } from '../../src/types';
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const AUTO_MATCH_TITLE = en['chat.designToolbox.action.auto-match.title'];
const VISUAL_POLISH_TITLE = en['chat.designToolbox.action.visual-polish.title'];
// The five non-featured actions surfaced inside the More → Design toolbox submenu.
const MOTION_TITLE = en['chat.designToolbox.action.motion.title'];
const MOTION_POLISH_TITLE = en['chat.designToolbox.action.motion-polish.title'];
const ANTI_AI_TITLE = en['chat.designToolbox.action.anti-ai-polish.title'];
const IMAGE_GEN_TITLE = en['chat.designToolbox.action.image-gen.title'];
const VIDEO_GEN_TITLE = en['chat.designToolbox.action.video-gen.title'];
function skill(id: string, name: string, category = 'creative-direction'): SkillSummary {
return {
id,
name,
description: `${name} skill`,
triggers: [],
mode: 'prototype',
surface: 'web',
category,
previewType: 'html',
designSystemRequired: false,
defaultFor: [],
upstream: '',
hasBody: true,
examplePrompt: '',
aggregatesExamples: false,
} as SkillSummary;
}
function renderActions(
overrides: Partial<Parameters<typeof NextStepActions>[0]> = {},
locale?: Locale,
) {
const handlers = {
onShare: vi.fn(),
onDownload: vi.fn(),
onToolboxAction: vi.fn(),
onPickSkill: vi.fn(),
onShareToOpenDesign: vi.fn(),
};
const ui = (
<NextStepActions
fileName="landing.html"
onShare={handlers.onShare}
onDownload={handlers.onDownload}
onToolboxAction={handlers.onToolboxAction}
onPickSkill={handlers.onPickSkill}
onShareToOpenDesign={handlers.onShareToOpenDesign}
skills={[
skill('creative-director', 'Creative Director'),
skill('emilkowalski-motion', 'Emil Kowalski Motion', 'animation-motion'),
skill('imagegen-frontend-web', 'Imagegen Frontend Web', 'image-generation'),
]}
toolboxSkillNames={{ 'auto-match': 'creative-director', 'visual-polish': 'impeccable-design-polish' }}
{...overrides}
/>
);
render(locale ? <I18nProvider initial={locale}>{ui}</I18nProvider> : ui);
return handlers;
}
describe('NextStepActions', () => {
it('renders the two featured rows and More', () => {
renderActions();
expect(screen.getByText(AUTO_MATCH_TITLE)).toBeTruthy();
expect(screen.getByText(VISUAL_POLISH_TITLE)).toBeTruthy();
expect(screen.getByTestId('next-step-toolbox-more')).toBeTruthy();
});
it('seeds the composer with the action id (no auto-send) when a featured row is clicked', () => {
const h = renderActions();
fireEvent.click(screen.getByTestId('next-step-toolbox-action-visual-polish'));
expect(h.onToolboxAction).toHaveBeenCalledWith('visual-polish');
});
it('uses design-system-specific primary rows for design-system projects', () => {
const onPromptAction = vi.fn();
renderActions({ variant: 'design-system', onPromptAction });
expect(screen.queryByText(AUTO_MATCH_TITLE)).toBeNull();
expect(screen.queryByText(VISUAL_POLISH_TITLE)).toBeNull();
expect(screen.getByText(en['nextStep.designSystemAiRefineTitle'])).toBeTruthy();
expect(screen.getByText(en['nextStep.designSystemAuditKitTitle'])).toBeTruthy();
fireEvent.click(screen.getByTestId('next-step-design-system-action-design-system-ai-refine'));
expect(onPromptAction).toHaveBeenCalledWith(expect.stringContaining('refine this design system in place'));
});
it('offers document handoff rows after plan mode produces only a document', () => {
const onPromptAction = vi.fn();
renderActions({
variant: 'plan',
fileName: 'plan.md',
planFileName: 'plan.md',
artifactFileName: null,
onPromptAction,
});
expect(screen.queryByText(AUTO_MATCH_TITLE)).toBeNull();
expect(screen.getByText(en['nextStep.planGenerateTitle'])).toBeTruthy();
expect(screen.getByText(en['nextStep.planImproveTitle'])).toBeTruthy();
expect(screen.queryByText(en['nextStep.planImproveArtifactTitle'])).toBeNull();
fireEvent.click(screen.getByTestId('next-step-plan-action-plan-generate-from-doc'));
expect(onPromptAction).toHaveBeenLastCalledWith(
expect.stringContaining('plan.md'),
{ sessionMode: 'design' },
);
fireEvent.click(screen.getByTestId('next-step-plan-action-plan-improve-doc'));
expect(onPromptAction).toHaveBeenLastCalledWith(
expect.stringContaining('plan.md'),
{ sessionMode: 'plan' },
);
});
it('offers artifact refinement after plan mode produces only an artifact', () => {
const onPromptAction = vi.fn();
renderActions({
variant: 'plan',
fileName: 'index.html',
planFileName: null,
artifactFileName: 'index.html',
onPromptAction,
});
expect(screen.queryByText(en['nextStep.planGenerateTitle'])).toBeNull();
expect(screen.queryByText(en['nextStep.planImproveTitle'])).toBeNull();
expect(screen.getByText(en['nextStep.planImproveArtifactTitle'])).toBeTruthy();
fireEvent.click(screen.getByTestId('next-step-plan-action-plan-improve-artifact'));
expect(onPromptAction).toHaveBeenLastCalledWith(
expect.stringContaining('index.html'),
{ sessionMode: 'design' },
);
});
it('offers a document/artifact merge when plan mode produces both', () => {
const onPromptAction = vi.fn();
renderActions({
variant: 'plan',
fileName: 'plan.md',
planFileName: 'plan.md',
artifactFileName: 'index.html',
onPromptAction,
});
expect(screen.queryByText(en['nextStep.planGenerateTitle'])).toBeNull();
expect(screen.queryByText(en['nextStep.planImproveTitle'])).toBeNull();
expect(screen.getByText(en['nextStep.planMergeTitle'])).toBeTruthy();
expect(screen.getByText(en['nextStep.planImproveArtifactTitle'])).toBeTruthy();
fireEvent.click(screen.getByTestId('next-step-plan-action-plan-merge-doc-artifact'));
expect(onPromptAction).toHaveBeenLastCalledWith(
expect.stringContaining('plan.md'),
{ sessionMode: 'design' },
);
expect(onPromptAction.mock.calls.at(-1)?.[0]).toContain('index.html');
});
it('uses brand-extraction primary rows for programmatic brand projects', () => {
const onAiOptimize = vi.fn();
const onCreateDesign = vi.fn();
renderActions({ variant: 'brand-extraction', onAiOptimize, onCreateDesign });
expect(screen.queryByText(AUTO_MATCH_TITLE)).toBeNull();
expect(screen.queryByText(VISUAL_POLISH_TITLE)).toBeNull();
expect(screen.getByText(en['nextStep.brandAiOptimizeTitle'])).toBeTruthy();
expect(screen.getByText(en['nextStep.brandAiOptimizeBody'])).toBeTruthy();
expect(screen.getByText(en['nextStep.brandCreateDesignTitle'])).toBeTruthy();
expect(screen.getByText(en['nextStep.brandCreateDesignBody'])).toBeTruthy();
expect(screen.getByTestId('next-step-toolbox-more')).toBeTruthy();
fireEvent.click(screen.getByTestId('next-step-brand-action-brand-ai-optimize'));
expect(onAiOptimize).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('next-step-brand-action-brand-create-design'));
expect(onCreateDesign).toHaveBeenCalledTimes(1);
});
it('offers continue extraction and agent fallback for incomplete brand extraction', () => {
const onContinueExtraction = vi.fn();
const onContinueAiExtraction = vi.fn();
renderActions({
variant: 'brand-programmatic-incomplete',
onContinueExtraction,
onContinueAiExtraction,
onCreateDesign: undefined,
});
expect(screen.getByText(en['nextStep.brandContinueExtractionTitle'])).toBeTruthy();
expect(screen.getByText(en['nextStep.brandContinueAiExtractionTitle'])).toBeTruthy();
expect(screen.queryByText(en['nextStep.brandCreateDesignTitle'])).toBeNull();
expect(screen.queryByText(en['nextStep.brandAiOptimizeTitle'])).toBeNull();
fireEvent.click(screen.getByTestId('next-step-brand-action-brand-continue-extraction'));
expect(onContinueExtraction).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('next-step-brand-action-brand-continue-ai-extraction'));
expect(onContinueAiExtraction).toHaveBeenCalledTimes(1);
});
it('offers only agent continuation for incomplete AI brand extraction', () => {
const onContinueAiExtraction = vi.fn();
renderActions({
variant: 'brand-ai-incomplete',
onContinueExtraction: vi.fn(),
onContinueAiExtraction,
onAiOptimize: vi.fn(),
onCreateDesign: vi.fn(),
});
expect(screen.getByText(en['nextStep.brandContinueAiExtractionTitle'])).toBeTruthy();
expect(screen.queryByText(en['nextStep.brandContinueExtractionTitle'])).toBeNull();
expect(screen.queryByText(en['nextStep.brandAiOptimizeTitle'])).toBeNull();
expect(screen.queryByText(en['nextStep.brandCreateDesignTitle'])).toBeNull();
fireEvent.click(screen.getByTestId('next-step-brand-action-brand-continue-ai-extraction'));
expect(onContinueAiExtraction).toHaveBeenCalledTimes(1);
});
it('offers ordinary project recovery prompts for incomplete turns without artifacts', () => {
const onPromptAction = vi.fn();
renderActions({
variant: 'project-incomplete',
fileName: null,
onPromptAction,
});
expect(screen.getByText(en['nextStep.projectContinueTitle'])).toBeTruthy();
expect(screen.getByText(en['nextStep.projectGenerateArtifactTitle'])).toBeTruthy();
fireEvent.click(screen.getByTestId('next-step-project-action-project-continue'));
expect(onPromptAction).toHaveBeenCalledWith(PROJECT_CONTINUE_PROMPT);
fireEvent.click(screen.getByTestId('next-step-project-action-project-generate-artifact'));
expect(onPromptAction).toHaveBeenCalledWith(PROJECT_GENERATE_ARTIFACT_PROMPT);
expect(PROJECT_GENERATE_ARTIFACT_PROMPT).toContain('semantic filename');
expect(PROJECT_GENERATE_ARTIFACT_PROMPT).not.toContain('usually index.html');
});
it('localizes incomplete-project recovery prompts in Chinese', () => {
const onPromptAction = vi.fn();
renderActions({
variant: 'project-incomplete',
fileName: null,
onPromptAction,
}, 'zh-CN');
fireEvent.click(screen.getByTestId('next-step-project-action-project-continue'));
expect(onPromptAction).toHaveBeenCalledWith(
expect.stringContaining('从已停止或未完成的回合继续处理'),
);
fireEvent.click(screen.getByTestId('next-step-project-action-project-generate-artifact'));
expect(onPromptAction).toHaveBeenCalledWith(
expect.stringContaining('现在生成缺失的项目产物'),
);
expect(onPromptAction).toHaveBeenCalledWith(
expect.stringContaining('语义化文件名'),
);
expect(onPromptAction).not.toHaveBeenCalledWith(
expect.stringContaining('Generate the missing project artifact now'),
);
expect(onPromptAction).not.toHaveBeenCalledWith(
expect.stringContaining('通常保存为 index.html'),
);
});
it('localizes design-system project prompts in Chinese', () => {
const onPromptAction = vi.fn();
renderActions({ variant: 'design-system', onPromptAction }, 'zh-CN');
fireEvent.click(screen.getByTestId('next-step-design-system-action-design-system-ai-refine'));
expect(onPromptAction).toHaveBeenCalledWith(
expect.stringContaining('原地优化这个设计系统'),
);
fireEvent.click(screen.getByTestId('next-step-design-system-action-design-system-audit-kit'));
expect(onPromptAction).toHaveBeenCalledWith(
expect.stringContaining('审查这个设计系统是否已经可用'),
);
expect(onPromptAction).not.toHaveBeenCalledWith(
expect.stringContaining('refine this design system in place'),
);
});
it('keeps brand-extraction rows visible and disabled while their actions are starting', () => {
renderActions({
variant: 'brand-extraction',
onAiOptimize: vi.fn(),
onCreateDesign: vi.fn(),
aiOptimizeBusy: true,
createDesignBusy: true,
});
const optimize = screen.getByTestId('next-step-brand-action-brand-ai-optimize') as HTMLButtonElement;
const create = screen.getByTestId('next-step-brand-action-brand-create-design') as HTMLButtonElement;
expect(screen.getByText(en['brandEnrichment.busy'])).toBeTruthy();
expect(screen.getByText(en['nextStep.createDesignBusy'])).toBeTruthy();
expect(optimize.disabled).toBe(true);
expect(create.disabled).toBe(true);
});
it('explains brand-extraction actions in hover detail', () => {
renderActions({
variant: 'brand-extraction',
onAiOptimize: vi.fn(),
onCreateDesign: vi.fn(),
});
fireEvent.mouseEnter(screen.getByTestId('next-step-brand-action-brand-ai-optimize'));
const tooltip = screen.getByRole('tooltip');
expect(within(tooltip).getByText(en['nextStep.brandAiOptimizeTitle'])).toBeTruthy();
expect(within(tooltip).getByText(en['nextStep.brandAiOptimizeBody'])).toBeTruthy();
});
it('reveals the matched @skill in the featured-row hover detail', () => {
renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-action-auto-match'));
expect(screen.getByText('@creative-director')).toBeTruthy();
});
it('opens the More menu with Design toolbox + Share on hover', () => {
renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
const menu = screen.getByTestId('next-step-more-menu');
expect(menu).toBeTruthy();
expect(screen.getByTestId('next-step-more-toolbox')).toBeTruthy();
expect(screen.getByTestId('next-step-more-share')).toBeTruthy();
});
it('cascades into searchable non-featured toolbox actions and global resources', () => {
renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-toolbox'));
const list = screen.getByTestId('next-step-toolbox-actions');
for (const title of [
MOTION_TITLE,
MOTION_POLISH_TITLE,
ANTI_AI_TITLE,
IMAGE_GEN_TITLE,
VIDEO_GEN_TITLE,
]) {
expect(within(list).getByText(title)).toBeTruthy();
}
// The two featured actions are not duplicated inside the submenu.
expect(within(list).queryByText(AUTO_MATCH_TITLE)).toBeNull();
expect(within(list).queryByText(VISUAL_POLISH_TITLE)).toBeNull();
expect(within(list).getByRole('textbox')).toBeTruthy();
expect(within(list).getByText(en['chat.designToolbox.resourcesSection'])).toBeTruthy();
expect(within(list).getByText('Creative Director')).toBeTruthy();
expect(within(list).getByText('Emil Kowalski Motion')).toBeTruthy();
});
it('filters actions and global resources from the toolbox search box', () => {
renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-toolbox'));
const list = screen.getByTestId('next-step-toolbox-actions');
fireEvent.change(within(list).getByRole('textbox'), { target: { value: 'image' } });
expect(within(list).getByText(IMAGE_GEN_TITLE)).toBeTruthy();
expect(within(list).getByText('Imagegen Frontend Web')).toBeTruthy();
expect(within(list).queryByText(MOTION_TITLE)).toBeNull();
});
it('keeps an action visible when searching by its preferred skill id (parity with the composer matcher)', () => {
renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-toolbox'));
const list = screen.getByTestId('next-step-toolbox-actions');
// `emilkowalski-motion` is the preferred skill of the `motion` action.
fireEvent.change(within(list).getByRole('textbox'), { target: { value: 'emilkowalski-motion' } });
// The skill resource row matches by id...
expect(within(list).getByTestId('next-step-toolbox-resource-emilkowalski-motion')).toBeTruthy();
// ...and the action it is the preferred skill for must stay visible too,
// instead of the action row disappearing while its resource row shows.
expect(within(list).getByTestId('next-step-toolbox-sub-action-motion')).toBeTruthy();
});
it('matches and renders a global resource by its localized text under a non-English locale', () => {
const localizedSkill = {
...skill('creative-director', 'creative-director'),
displayName: { 'zh-CN': '创意总监' },
descriptionI18n: { 'zh-CN': 'AI 创意总监,负责整体审美方向' },
} as SkillSummary;
renderActions({ skills: [localizedSkill] }, 'zh-CN');
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-toolbox'));
const list = screen.getByTestId('next-step-toolbox-actions');
fireEvent.change(within(list).getByRole('textbox'), { target: { value: '创意总监' } });
// The localized query matches (parity with the composer's localized index)...
expect(within(list).getByTestId('next-step-toolbox-resource-creative-director')).toBeTruthy();
// ...and the row renders the localized name rather than the raw id.
expect(within(list).getByText('创意总监')).toBeTruthy();
});
it('keeps the paired action visible for a localized preferred-skill query (action/resource parity under a non-English locale)', () => {
const motionSkill = {
...skill('emilkowalski-motion', 'emilkowalski-motion', 'animation-motion'),
displayName: { 'zh-CN': '动效大师' },
} as SkillSummary;
renderActions({ skills: [motionSkill] }, 'zh-CN');
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-toolbox'));
const list = screen.getByTestId('next-step-toolbox-actions');
fireEvent.change(within(list).getByRole('textbox'), { target: { value: '动效大师' } });
// The resource row matches the localized name...
expect(within(list).getByTestId('next-step-toolbox-resource-emilkowalski-motion')).toBeTruthy();
// ...and the action it is the preferred skill for must stay visible, instead
// of the action matcher ignoring the localized skill text and hiding it.
expect(within(list).getByTestId('next-step-toolbox-sub-action-motion')).toBeTruthy();
});
it('seeds the composer with a non-featured action id when picked from the submenu', () => {
const h = renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-toolbox'));
fireEvent.click(screen.getByTestId('next-step-toolbox-sub-action-motion'));
expect(h.onToolboxAction).toHaveBeenCalledWith('motion');
});
it('seeds the composer with a global resource skill when picked from the submenu', () => {
const h = renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-toolbox'));
fireEvent.click(screen.getByTestId('next-step-toolbox-resource-emilkowalski-motion'));
expect(h.onPickSkill).toHaveBeenCalledWith('emilkowalski-motion');
});
it('cascades into Share / Download / Contribute and routes each action', () => {
const h = renderActions();
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-share'));
expect(screen.getByTestId('next-step-share-menu')).toBeTruthy();
fireEvent.click(screen.getByTestId('next-step-share-share'));
expect(h.onShare).toHaveBeenCalledWith('landing.html');
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-share'));
fireEvent.click(screen.getByTestId('next-step-share-download'));
expect(h.onDownload).toHaveBeenCalledWith('landing.html');
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
fireEvent.mouseEnter(screen.getByTestId('next-step-more-share'));
fireEvent.click(screen.getByTestId('next-step-share-contribute'));
expect(h.onShareToOpenDesign).toHaveBeenCalledTimes(1);
});
it('hides the toolbox rows when no toolbox handler is wired', () => {
renderActions({ onToolboxAction: undefined });
expect(screen.queryByTestId('next-step-toolbox-action-auto-match')).toBeNull();
});
});