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

640 lines
23 KiB
TypeScript

// @vitest-environment jsdom
// Regression coverage for the shared composer "+" menu (replaces the deleted
// ChatComposer.tools-menu-caret.test.tsx, #3195): the connector / plugin / MCP
// pick rows must cancel `mousedown` so the editor keeps focus and the caller's
// insertMention lands at the caret instead of the draft end.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { cleanup, act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ComponentProps } from 'react';
import { ComposerPlusMenu } from '../../src/components/ComposerPlusMenu';
import { I18nProvider } from '../../src/i18n';
import type { Locale } from '../../src/i18n/types';
afterEach(() => {
cleanup();
});
const CONNECTOR = { id: 'c1', name: 'Notion', status: 'connected' } as never;
const PLUGIN = { id: 'p1', title: 'Deck Maker', manifest: {} } as never;
const MCP_SERVER = { id: 'm1', label: 'Linear', enabled: true } as never;
function renderMenu(
overrides: Partial<ComponentProps<typeof ComposerPlusMenu>> = {},
options: { chatBoundary?: Pick<DOMRect, 'left' | 'right'> } = {},
) {
const props: ComponentProps<typeof ComposerPlusMenu> = {
connectors: [CONNECTOR],
onPickConnector: vi.fn(),
plugins: [PLUGIN],
onPickPlugin: vi.fn(),
mcpServers: [MCP_SERVER],
onPickMcp: vi.fn(),
onAttachFiles: vi.fn(),
triggerTestId: 'plus-trigger',
...overrides,
};
const view = render(
<I18nProvider initial={'en' as Locale}>
<div className={options.chatBoundary ? 'split-chat-slot' : undefined} data-testid="menu-host">
<ComposerPlusMenu {...props} />
</div>
</I18nProvider>,
);
if (options.chatBoundary) {
const host = screen.getByTestId('menu-host');
host.getBoundingClientRect = () =>
({
x: options.chatBoundary?.left ?? 0,
y: 0,
top: 0,
left: options.chatBoundary?.left ?? 0,
right: options.chatBoundary?.right ?? 0,
bottom: 420,
width: (options.chatBoundary?.right ?? 0) - (options.chatBoundary?.left ?? 0),
height: 420,
toJSON: () => ({}),
}) as DOMRect;
}
return { props, ...view };
}
// A pick row cancels mousedown so focus stays on the editor; assert the
// dispatched mousedown event is defaultPrevented.
function expectPickRowPreventsMousedown(name: RegExp) {
const row = screen.getByRole('menuitem', { name });
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
row.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
}
describe('ComposerPlusMenu pick-row caret protection', () => {
it('cancels mousedown on the connector / plugin / MCP pick rows', () => {
renderMenu();
fireEvent.click(screen.getByTestId('plus-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: /Connectors/i }));
expectPickRowPreventsMousedown(/Notion/i);
fireEvent.click(screen.getByRole('menuitem', { name: /Plugins/i }));
expectPickRowPreventsMousedown(/Deck Maker/i);
fireEvent.click(screen.getByRole('menuitem', { name: /^MCP/i }));
expectPickRowPreventsMousedown(/Linear/i);
});
it('keeps the plugin flyout open when filtering reflows fire a mouseleave mid-search', () => {
vi.useFakeTimers();
try {
renderMenu({
plugins: [
PLUGIN,
{ id: 'p2', title: 'Slide Builder', manifest: {} } as never,
],
});
fireEvent.click(screen.getByTestId('plus-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: /Plugins/i }));
// The user clicks into the search box (focus enters the flyout) and types,
// pruning the list. In a real browser the shrinking list reflows rows out
// from under the stationary cursor, so Chromium synthesizes a `mouseleave`
// on the flyout even though the pointer never moved.
const search = screen.getByPlaceholderText('Plugins') as HTMLInputElement;
search.focus();
fireEvent.change(search, { target: { value: 'deck' } });
const flyout = document.querySelector('.plus-menu__flyout') as HTMLElement;
fireEvent.mouseLeave(flyout);
// The hover-close grace period elapses; the panel must survive because the
// search box still owns focus — yanking it away would make the plugin
// impossible to pick.
act(() => {
vi.advanceTimersByTime(400);
});
expect(screen.queryByPlaceholderText('Plugins')).not.toBeNull();
expect(screen.getByRole('menuitem', { name: /Deck Maker/i })).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it('resets the shared search query when switching submenus', () => {
renderMenu();
fireEvent.click(screen.getByTestId('plus-trigger'));
fireEvent.click(screen.getByRole('menuitem', { name: /Plugins/i }));
const pluginSearch = screen.getByPlaceholderText('Plugins') as HTMLInputElement;
fireEvent.change(pluginSearch, { target: { value: 'deck' } });
expect(pluginSearch.value).toBe('deck');
// Moving to the MCP submenu must clear the query so it doesn't cross-filter.
fireEvent.click(screen.getByRole('menuitem', { name: /^MCP/i }));
const mcpSearch = screen.getByPlaceholderText('MCP') as HTMLInputElement;
expect(mcpSearch.value).toBe('');
expect(screen.getByText('Linear')).toBeTruthy();
});
it('portals the menu and constrains it to the available viewport height', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 280 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 420 });
try {
renderMenu();
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 8,
y: 376,
top: 376,
left: 8,
right: 36,
bottom: 404,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const menu = screen.getByRole('menu');
expect(menu.parentElement).toBe(document.body);
expect(menu.style.left).toBe('12px');
expect(menu.style.width).toBe('208px');
expect(menu.style.maxHeight).toBe('356px');
expect(menu.style.top).toBe('auto');
expect(menu.style.bottom).toBe('52px');
expect(screen.getByRole('menuitem', { name: /Connectors/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Plugins/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /^MCP/i })).toBeTruthy();
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('can open downward for the home surface even when there is enough room above', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 720 });
try {
renderMenu({ placementPreference: 'down' });
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 280,
y: 320,
top: 320,
left: 280,
right: 312,
bottom: 352,
width: 32,
height: 32,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const menu = screen.getByRole('menu');
expect(menu.style.top).toBe('360px');
expect(menu.style.bottom).toBe('auto');
expect(menu.style.width).toBe('208px');
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('opens flyouts to the left when the right edge would overflow', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 420 });
try {
renderMenu();
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 620,
y: 376,
top: 376,
left: 620,
right: 648,
bottom: 404,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const menu = screen.getByRole('menu');
expect(menu.className).toContain('plus-menu__popup--flyout-left');
fireEvent.click(screen.getByRole('menuitem', { name: /Plugins/i }));
expect(screen.getByRole('menuitem', { name: /Deck Maker/i })).toBeTruthy();
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('contains flyouts inside the menu when neither side has enough room', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 360 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 420 });
try {
renderMenu();
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 220,
y: 376,
top: 376,
left: 220,
right: 248,
bottom: 404,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const menu = screen.getByRole('menu');
expect(menu.className).toContain('plus-menu__popup--flyout-contained');
fireEvent.click(screen.getByRole('menuitem', { name: /Plugins/i }));
expect(screen.getByRole('menuitem', { name: /Deck Maker/i })).toBeTruthy();
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('contains flyouts inside the menu when the chat pane clips the right side', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 640 });
try {
renderMenu({}, { chatBoundary: { left: 0, right: 460 } });
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 24,
y: 576,
top: 576,
left: 24,
right: 52,
bottom: 604,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const menu = screen.getByRole('menu');
expect(menu.className).toContain('plus-menu__popup--flyout-contained');
fireEvent.click(screen.getByRole('menuitem', { name: /Plugins/i }));
expect(screen.getByRole('menuitem', { name: /Deck Maker/i })).toBeTruthy();
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('limits flyout height to the visible viewport below the hovered row', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 520 });
try {
renderMenu();
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 24,
y: 468,
top: 468,
left: 24,
right: 52,
bottom: 496,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const pluginParent = screen.getByRole('menuitem', { name: /Plugins/i });
const pluginRow = pluginParent.closest('.plus-menu__submenu-row') as HTMLDivElement;
pluginRow.getBoundingClientRect = () =>
({
x: 24,
y: 210,
top: 210,
left: 24,
right: 214,
bottom: 242,
width: 190,
height: 32,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(pluginParent);
const menu = screen.getAllByRole('menu')[0];
expect(menu).toBeDefined();
expect(menu?.className).toContain('plus-menu__popup--flyout-y-down');
expect(menu?.style.getPropertyValue('--plus-menu-flyout-max-height')).toBe('303px');
expect(screen.getByRole('menuitem', { name: /Deck Maker/i })).toBeTruthy();
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('opens low flyouts upward when the hovered row is near the viewport bottom', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 520 });
try {
renderMenu({
toolboxLabel: 'Design toolbox',
renderToolbox: () => <div>Toolbox content</div>,
});
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 24,
y: 468,
top: 468,
left: 24,
right: 52,
bottom: 496,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const toolboxParent = screen.getByRole('menuitem', { name: /Design toolbox/i });
const toolboxRow = toolboxParent.closest('.plus-menu__submenu-row') as HTMLDivElement;
toolboxRow.getBoundingClientRect = () =>
({
x: 24,
y: 330,
top: 330,
left: 24,
right: 214,
bottom: 362,
width: 190,
height: 32,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(toolboxParent);
const menu = screen.getAllByRole('menu')[0];
expect(menu).toBeDefined();
expect(menu?.className).toContain('plus-menu__popup--flyout-y-up');
expect(menu?.style.getPropertyValue('--plus-menu-flyout-max-height')).toBe('320px');
expect(screen.getByText('Toolbox content')).toBeTruthy();
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('keeps low MCP flyouts fixed and bounded while the main popup scrolls', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 360 });
try {
renderMenu({ placementPreference: 'down', onAddMcp: vi.fn() });
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 240,
y: 100,
top: 100,
left: 240,
right: 268,
bottom: 128,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const mcpParent = screen.getByRole('menuitem', { name: /^MCP/i });
const mcpRow = mcpParent.closest('.plus-menu__submenu-row') as HTMLDivElement;
mcpRow.getBoundingClientRect = () =>
({
x: 244,
y: 300,
top: 300,
left: 244,
right: 444,
bottom: 328,
width: 200,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(mcpParent);
const menu = screen.getAllByRole('menu')[0];
expect(menu).toBeDefined();
expect(menu?.style.maxHeight).toBe('212px');
expect(menu?.className).toContain('plus-menu__popup--flyout-y-up');
const flyout = document.querySelector<HTMLElement>('.plus-menu__flyout');
expect(flyout).not.toBeNull();
expect(flyout?.style.width).toBe('260px');
expect(flyout?.style.maxHeight).toBe('320px');
expect(flyout?.style.top).toBe('auto');
expect(flyout?.style.bottom).toBe('37px');
const css = readFileSync(join(process.cwd(), 'src/styles/home/plus-menu.css'), 'utf8');
expect(css).toContain('overflow-y: auto;');
expect(css).toContain('position: fixed;');
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('keeps contained design toolbox flyouts within the popup width', () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 360 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 420 });
try {
renderMenu({
toolboxLabel: 'Design toolbox',
renderToolbox: () => (
<div className="composer-design-toolbox-menu">Contained toolbox</div>
),
});
const trigger = screen.getByTestId('plus-trigger') as HTMLButtonElement;
trigger.getBoundingClientRect = () =>
({
x: 220,
y: 376,
top: 376,
left: 220,
right: 248,
bottom: 404,
width: 28,
height: 28,
toJSON: () => ({}),
}) as DOMRect;
fireEvent.click(trigger);
const menu = screen.getByRole('menu');
expect(menu.className).toContain('plus-menu__popup--flyout-contained');
fireEvent.click(screen.getByRole('menuitem', { name: /Design toolbox/i }));
expect(screen.getByText('Contained toolbox')).toBeTruthy();
const css = readFileSync(join(process.cwd(), 'src/styles/home/plus-menu.css'), 'utf8');
expect(css).toContain('.plus-menu__popup--flyout-contained .plus-menu__flyout .composer-design-toolbox-menu');
expect(css).toContain('width: 100%;');
expect(css).toContain('max-width: 100%;');
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
});
// Usability guard (user request): every module offered by the "+" menu must be
// wired to a working handler, not just rendered. These tests open the menu and
// click each row — direct items and every submenu's pick + "Add …" row — and
// assert the corresponding callback fires. A row that renders but no longer
// calls its handler (a refactor dropping the onClick, a mis-wired prop) turns
// this red.
describe('ComposerPlusMenu module wiring', () => {
const SKILL = {
id: 's1',
name: 'Wireframe Kit',
description: 'Skill fixture.',
triggers: [],
mode: 'prototype',
category: null,
source: 'built-in',
previewType: 'html',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: '',
aggregatesExamples: false,
} as never;
function openMenu() {
fireEvent.click(screen.getByTestId('plus-trigger'));
}
it('invokes each Files / Code / Designs row handler', () => {
const { props } = renderMenu({
onReferenceProject: vi.fn(),
onLinkLocalCode: vi.fn(),
onImportFigma: vi.fn(),
onShowFigmaHelp: vi.fn(),
onOpenDesignSystems: vi.fn(),
});
// Each row closes the menu, so re-open before clicking the next one.
const clickRow = (testId: string) => {
openMenu();
fireEvent.click(screen.getByTestId(testId));
};
clickRow('composer-plus-attach');
expect(props.onAttachFiles).toHaveBeenCalledTimes(1);
clickRow('composer-plus-reference-project');
expect(props.onReferenceProject).toHaveBeenCalledTimes(1);
clickRow('composer-plus-local-code');
expect(props.onLinkLocalCode).toHaveBeenCalledTimes(1);
clickRow('composer-plus-figma');
expect(props.onImportFigma).toHaveBeenCalledTimes(1);
clickRow('composer-plus-figma-help');
expect(props.onShowFigmaHelp).toHaveBeenCalledTimes(1);
clickRow('composer-plus-design-system');
expect(props.onOpenDesignSystems).toHaveBeenCalledTimes(1);
});
it('invokes every submenu pick and "Add …" row handler', () => {
const { props } = renderMenu({
onAddConnector: vi.fn(),
onAddPlugin: vi.fn(),
onAddMcp: vi.fn(),
skills: [SKILL],
onPickSkill: vi.fn(),
});
// A submenu flyout opens on click and stays open until a pick/add row
// closes the whole menu, so re-open the menu + submenu for each row.
const openSubmenu = (rowName: RegExp) => {
openMenu();
fireEvent.click(screen.getByRole('menuitem', { name: rowName }));
};
openSubmenu(/Connectors/i);
fireEvent.click(screen.getByRole('menuitem', { name: /Notion/i }));
expect(props.onPickConnector).toHaveBeenCalledWith(CONNECTOR);
openSubmenu(/Connectors/i);
fireEvent.click(screen.getByRole('menuitem', { name: 'Add connectors' }));
expect(props.onAddConnector).toHaveBeenCalledTimes(1);
openSubmenu(/Plugins/i);
fireEvent.click(screen.getByRole('menuitem', { name: /Deck Maker/i }));
expect(props.onPickPlugin).toHaveBeenCalledWith(PLUGIN);
openSubmenu(/Plugins/i);
fireEvent.click(screen.getByRole('menuitem', { name: 'Add plugin' }));
expect(props.onAddPlugin).toHaveBeenCalledTimes(1);
openSubmenu(/^Skills/i);
fireEvent.click(screen.getByRole('menuitem', { name: /Wireframe Kit/i }));
expect(props.onPickSkill).toHaveBeenCalledWith(SKILL);
openSubmenu(/^MCP/i);
fireEvent.click(screen.getByRole('menuitem', { name: /Linear/i }));
expect(props.onPickMcp).toHaveBeenCalledWith(MCP_SERVER);
openSubmenu(/^MCP/i);
fireEvent.click(screen.getByRole('menuitem', { name: 'Add MCP server' }));
expect(props.onAddMcp).toHaveBeenCalledTimes(1);
});
it('renders the Design toolbox submenu when a toolbox renderer is provided', () => {
renderMenu({ renderToolbox: () => <div>Toolbox contents</div> });
openMenu();
fireEvent.click(screen.getByRole('menuitem', { name: /Design toolbox/i }));
expect(screen.getByText('Toolbox contents')).toBeTruthy();
});
});