// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatComposer } from '../../src/components/ChatComposer'; import { I18nProvider } from '../../src/i18n'; import type { Locale } from '../../src/i18n/types'; const COMMUNITY_PLUGIN = { id: 'community-deck', title: 'Community Deck', version: '1.0.0', trust: 'restricted' as const, sourceKind: 'bundled' as const, source: 'bundled/community-deck', capabilitiesGranted: [], manifest: { name: 'community-deck', title: 'Community Deck', description: 'Official deck starter', od: { kind: 'skill' }, }, fsPath: '/plugins/community-deck', installedAt: 0, updatedAt: 0, }; const USER_PLUGIN = { ...COMMUNITY_PLUGIN, id: 'my-export', title: 'My Export', sourceKind: 'local' as const, source: '/plugins/my-export', manifest: { ...COMMUNITY_PLUGIN.manifest, name: 'my-export', title: 'My Export', description: 'Private export workflow', }, }; function makePlugin(overrides: { id: string; title: string; description: string }): typeof COMMUNITY_PLUGIN { return { ...COMMUNITY_PLUGIN, id: overrides.id, title: overrides.title, source: `bundled/${overrides.id}`, manifest: { ...COMMUNITY_PLUGIN.manifest, name: overrides.id, title: overrides.title, description: overrides.description, }, fsPath: `/plugins/${overrides.id}`, }; } const SKILL = { id: 'deck-builder', name: 'Deck Builder', description: 'Build a polished slide deck.', triggers: ['deck'], mode: 'deck' as const, previewType: 'html', designSystemRequired: false, defaultFor: [], upstream: null, hasBody: true, examplePrompt: 'Make a deck', aggregatesExamples: false, }; function makeSkill(overrides: Partial): typeof SKILL { return { ...SKILL, id: overrides.id ?? SKILL.id, name: overrides.name ?? SKILL.name, description: overrides.description ?? SKILL.description, triggers: overrides.triggers ?? SKILL.triggers, mode: overrides.mode ?? SKILL.mode, previewType: overrides.previewType ?? SKILL.previewType, designSystemRequired: overrides.designSystemRequired ?? SKILL.designSystemRequired, defaultFor: overrides.defaultFor ?? SKILL.defaultFor, upstream: overrides.upstream ?? SKILL.upstream, hasBody: overrides.hasBody ?? SKILL.hasBody, examplePrompt: overrides.examplePrompt ?? SKILL.examplePrompt, aggregatesExamples: overrides.aggregatesExamples ?? SKILL.aggregatesExamples, }; } const MCP_SERVER = { id: 'slack', label: 'Slack MCP', transport: 'stdio' as const, enabled: true, command: 'slack-mcp', }; const MCP_TEMPLATE = { id: 'figma-context', label: 'Figma Context', description: 'Read design frames from Figma files.', transport: 'stdio' as const, category: 'design-systems' as const, command: 'figma-mcp', }; const APPLY_RESULT = { ok: true, query: 'Run plugin.', contextItems: [], inputs: [], assets: [], mcpServers: [], trust: 'restricted', capabilitiesGranted: ['prompt:inject'], capabilitiesRequired: ['prompt:inject'], appliedPlugin: { snapshotId: 'snap-1', pluginId: USER_PLUGIN.id, pluginVersion: '1.0.0', manifestSourceDigest: 'a'.repeat(64), inputs: {}, resolvedContext: { items: [] }, capabilitiesGranted: ['prompt:inject'], capabilitiesRequired: ['prompt:inject'], assetsStaged: [], taskKind: 'new-generation', appliedAt: 0, connectorsRequired: [], connectorsResolved: [], mcpServers: [], status: 'fresh', }, projectMetadata: {}, }; let fetchMock: ReturnType; let plugins = [COMMUNITY_PLUGIN, USER_PLUGIN]; let skills = [SKILL]; let servers = [MCP_SERVER]; let templates = [MCP_TEMPLATE]; function renderComposer( overrides: Partial> = {}, options: { locale?: Locale } = {}, ) { const tree = ( 'project-1'} onSend={vi.fn()} onStop={vi.fn()} onOpenMcpSettings={vi.fn()} skills={skills} {...overrides} /> ); if (options.locale) { return render( {tree} , ); } return render( tree, ); } beforeEach(() => { plugins = [COMMUNITY_PLUGIN, USER_PLUGIN]; skills = [SKILL]; servers = [MCP_SERVER]; templates = [MCP_TEMPLATE]; fetchMock = vi.fn(async (url: string, init?: RequestInit) => { if (url === '/api/mcp/servers') { return new Response(JSON.stringify({ servers, templates }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/plugins') { return new Response(JSON.stringify({ plugins }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url.includes('/api/plugins/') && url.endsWith('/apply')) { return new Response(JSON.stringify(APPLY_RESULT), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/skills') { return new Response(JSON.stringify({ skills }), { status: 200, headers: { 'content-type': 'application/json' }, }); } if (url === '/api/projects/project-1' && init?.method === 'PATCH') { return new Response(JSON.stringify({ project: { id: 'project-1', skillId: SKILL.id } }), { status: 200, headers: { 'content-type': 'application/json' }, }); } throw new Error(`unexpected fetch ${url}`); }); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); cleanup(); }); describe('ChatComposer context pickers', () => { it('opens the @ panel even when every source is empty', async () => { plugins = []; skills = []; servers = []; renderComposer(); fireEvent.change(screen.getByTestId('chat-composer-input'), { target: { value: '@', selectionStart: 1 }, }); expect(screen.getByTestId('mention-popover')).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Plugins' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Skills' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'MCP' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Connectors' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Design files' })).toBeTruthy(); expect(screen.getByText('Search plugins, skills, MCP servers, connectors, and Design Files.')).toBeTruthy(); }); it('opens the same @ panel from the composer mention button', async () => { renderComposer({ projectFiles: [ { path: 'designs/landing.html', name: 'landing.html', kind: 'html', mime: 'text/html', mtime: 1, size: 128, }, ], }); fireEvent.click(screen.getByRole('button', { name: 'Open mention picker' })); expect(screen.getByTestId('mention-popover')).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Plugins' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Skills' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'MCP' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Connectors' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Design files' })).toBeTruthy(); await waitFor(() => expect(screen.getByText('Community Deck')).toBeTruthy()); expect(screen.getByText('Deck Builder')).toBeTruthy(); expect(screen.getByText('Slack MCP')).toBeTruthy(); expect(screen.getByText('designs/landing.html')).toBeTruthy(); }); it('navigates typed @ results with arrow keys and picks the active result', async () => { renderComposer(); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@', selectionStart: 1 }, }); await waitFor(() => expect(screen.getByText('My Export')).toBeTruthy()); const popover = screen.getByTestId('mention-popover'); const selectedText = () => popover.querySelector('[role="option"][aria-selected="true"]')?.textContent ?? ''; expect(selectedText()).toContain('Community Deck'); expect(input.getAttribute('aria-activedescendant')).toBe('chat-composer-mention-option-0'); fireEvent.keyDown(input, { key: 'ArrowDown' }); expect(selectedText()).toContain('My Export'); expect(input.getAttribute('aria-activedescendant')).toBe('chat-composer-mention-option-1'); fireEvent.keyDown(input, { key: 'ArrowUp' }); expect(selectedText()).toContain('Community Deck'); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => expect(input.value).toBe('@My Export ')); expect(screen.queryByTestId('mention-popover')).toBeNull(); }); it('supports keyboard selection after opening the @ picker button', async () => { plugins = []; skills = []; servers = []; renderComposer({ projectFiles: [ { path: 'designs/landing.html', name: 'landing.html', kind: 'html', mime: 'text/html', mtime: 1, size: 128, }, ], }); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.click(screen.getByRole('button', { name: 'Open mention picker' })); await waitFor(() => expect(input.value).toBe('@')); expect(screen.getByTestId('mention-popover')).toBeTruthy(); expect( screen .getByTestId('mention-popover') .querySelector('[role="option"][aria-selected="true"]')?.textContent, ).toContain('designs/landing.html'); fireEvent.keyDown(input, { key: 'Tab' }); expect(input.value).toBe('@designs/landing.html '); expect(screen.getByTestId('staged-attachments').textContent).toContain('landing.html'); expect(screen.queryByTestId('mention-popover')).toBeNull(); }); it('closes the @ picker when the tools panel opens', async () => { renderComposer(); fireEvent.click(screen.getByRole('button', { name: 'Open mention picker' })); expect(screen.getByTestId('mention-popover')).toBeTruthy(); fireEvent.click(screen.getByLabelText('Open resources menu')); expect(screen.queryByTestId('mention-popover')).toBeNull(); await waitFor(() => expect(screen.getByText('Community Deck')).toBeTruthy()); }); it('does not hijack Enter or arrows when @ search has no results', async () => { plugins = []; skills = []; servers = []; renderComposer(); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@missing', selectionStart: 8 }, }); expect(screen.getByText('No results for “missing”.')).toBeTruthy(); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' }); expect(input.value).toBe('@missing'); expect(screen.getByTestId('mention-popover')).toBeTruthy(); fireEvent.keyDown(input, { key: 'Escape' }); expect(screen.queryByTestId('mention-popover')).toBeNull(); }); it('ranks direct plugin name matches above incidental substring matches', async () => { plugins = [ makePlugin({ id: 'stone-staircase', title: '3D Stone Staircase Evolution Infographic', description: 'Transforms a flat evolutionary timeline into a realistic 3D stone staircase infographic.', }), makePlugin({ id: 'airbnb', title: 'Airbnb', description: 'Travel marketplace. Warm coral accent, photography-driven, rounded UI.', }), makePlugin({ id: 'airtable', title: 'Airtable', description: 'Spreadsheet-database hybrid. Colorful, friendly, structured data aesthetic.', }), makePlugin({ id: 'dcf-valuation', title: 'Dcf Valuation', description: 'Discounted cash flow valuation and intrinsic value analysis using fair value assumptions.', }), ]; renderComposer(); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@air', selectionStart: 4 }, }); await waitFor(() => expect(screen.getByText('Airtable')).toBeTruthy()); const popover = screen.getByTestId('mention-popover'); const pluginNames = Array.from( popover.querySelectorAll('.mention-section [role="option"] strong'), (node) => node.textContent, ); expect(pluginNames.slice(0, 3)).toEqual([ 'Airbnb', 'Airtable', '3D Stone Staircase Evolution Infographic', ]); expect( popover.querySelector('[role="option"][aria-selected="true"]')?.textContent, ).toContain('Airbnb'); }); it('localizes @ panel tabs and empty states in Chinese mode', async () => { plugins = []; skills = []; servers = []; renderComposer({}, { locale: 'zh-CN' }); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@', selectionStart: 1 }, }); expect(screen.getByRole('tab', { name: '全部' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '插件' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '技能' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'MCP' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '连接器' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '设计文件' })).toBeTruthy(); expect(screen.getByText('搜索插件、技能、MCP 服务器、连接器和设计文件。')).toBeTruthy(); fireEvent.change(input, { target: { value: '@missing', selectionStart: 8 }, }); expect(screen.getByText('没有找到“missing”的结果。')).toBeTruthy(); expect(screen.queryByText('No results for “missing”.')).toBeNull(); }); it('selects an MCP server from @ search and keeps the inline token visible', async () => { renderComposer(); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@sl', selectionStart: 3 }, }); await waitFor(() => expect(screen.getByText('Slack MCP')).toBeTruthy()); fireEvent.click(screen.getByText('Slack MCP')); expect(input.value).toBe('@Slack MCP '); expect(screen.getByTestId('chat-composer-mention-overlay').textContent).toContain('@Slack MCP'); }); it('applies a skill from @ search and reports the active project skill', async () => { const onProjectSkillChange = vi.fn(); renderComposer({ onProjectSkillChange }); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@deck', selectionStart: 5 }, }); await waitFor(() => expect(screen.getByText('Deck Builder')).toBeTruthy()); fireEvent.click(screen.getByText('Deck Builder')); await waitFor(() => expect(onProjectSkillChange).toHaveBeenCalledWith('deck-builder')); expect(input.value).toBe('@Deck Builder '); expect(screen.getByTestId('chat-composer-mention-overlay').textContent).toContain('@Deck Builder'); }); it('shows all matching skills and ranks exact prefix matches first', async () => { skills = [ makeSkill({ id: 'story-brief', name: 'Story Brief', description: 'Use when planning audit work.', triggers: ['writing'], }), ...Array.from({ length: 9 }, (_, index) => makeSkill({ id: `audit-helper-${index + 1}`, name: `Audit Helper ${index + 1}`, description: `Audit support workflow ${index + 1}.`, triggers: [`audit-${index + 1}`], }), ), makeSkill({ id: 'accessibility-review', name: 'Accessibility Review', description: 'Audit accessible interaction details.', triggers: ['a11y-audit'], }), ]; renderComposer(); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@audit', selectionStart: 6 }, }); await waitFor(() => expect(screen.getByText('Audit Helper 9')).toBeTruthy()); const skillNames = Array.from( screen.getByTestId('mention-popover').querySelectorAll('.mention-item strong'), (node) => node.textContent, ); expect(skillNames).toContain('Audit Helper 9'); expect(skillNames.indexOf('Audit Helper 1')).toBeLessThan(skillNames.indexOf('Story Brief')); expect(skillNames.indexOf('Audit Helper 9')).toBeLessThan(skillNames.indexOf('Accessibility Review')); }); it('applies a plugin from @ search and keeps the plugin token inline', async () => { renderComposer(); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@export', selectionStart: 7 }, }); await waitFor(() => expect(screen.getByText('My Export')).toBeTruthy()); fireEvent.click(screen.getByText('My Export')); await waitFor(() => expect(input.value).toBe('@My Export ')); expect(screen.getByTestId('chat-composer-mention-overlay').textContent).toContain('@My Export'); }); it('removes the inline design file token when its staged chip is removed', async () => { renderComposer({ projectFiles: [ { path: 'designs/landing.html', name: 'landing.html', kind: 'html', mime: 'text/html', mtime: 1, size: 128, }, ], }); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: 'Use @landing', selectionStart: 12 }, }); await waitFor(() => expect(screen.getByText('designs/landing.html')).toBeTruthy()); fireEvent.click(screen.getByText('designs/landing.html')); expect(input.value).toBe('Use @designs/landing.html '); expect(screen.getByTestId('staged-attachments').textContent).toContain('landing.html'); fireEvent.click(screen.getByLabelText('Remove landing.html')); expect(input.value).toBe('Use '); expect(screen.queryByTestId('staged-attachments')).toBeNull(); }); it('preserves surrounding draft formatting when removing a design file token', async () => { renderComposer({ projectFiles: [ { path: 'designs/landing.html', name: 'landing.html', kind: 'html', mime: 'text/html', mtime: 1, size: 128, }, ], }); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; const draft = 'Plan:\n\n@landing\n\nKeep spacing'; fireEvent.change(input, { target: { value: draft, selectionStart: 'Plan:\n\n@landing'.length }, }); await waitFor(() => expect(screen.getByText('designs/landing.html')).toBeTruthy()); fireEvent.click(screen.getByText('designs/landing.html')); expect(input.value).toBe('Plan:\n\n@designs/landing.html \n\nKeep spacing'); fireEvent.click(screen.getByLabelText('Remove landing.html')); expect(input.value).toBe('Plan:\n\n\n\nKeep spacing'); expect(screen.queryByTestId('staged-attachments')).toBeNull(); }); it('removes a design file token when punctuation follows it', async () => { renderComposer({ projectFiles: [ { path: 'designs/landing.html', name: 'landing.html', kind: 'html', mime: 'text/html', mtime: 1, size: 128, }, ], }); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: 'Use @landing', selectionStart: 12 }, }); await waitFor(() => expect(screen.getByText('designs/landing.html')).toBeTruthy()); fireEvent.click(screen.getByText('designs/landing.html')); fireEvent.change(input, { target: { value: 'Use @designs/landing.html, please', selectionStart: 'Use @designs/landing.html, please'.length, }, }); fireEvent.click(screen.getByLabelText('Remove landing.html')); expect(input.value).toBe('Use , please'); expect(screen.queryByTestId('staged-attachments')).toBeNull(); }); it('removes a quoted design file token when its chip is removed', async () => { renderComposer({ projectFiles: [ { path: 'designs/landing.html', name: 'landing.html', kind: 'html', mime: 'text/html', mtime: 1, size: 128, }, ], }); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; fireEvent.change(input, { target: { value: '@landing', selectionStart: 8 }, }); await waitFor(() => expect(screen.getByText('designs/landing.html')).toBeTruthy()); fireEvent.click(screen.getByText('designs/landing.html')); fireEvent.change(input, { target: { value: '"@designs/landing.html"', selectionStart: '"@designs/landing.html"'.length, }, }); fireEvent.click(screen.getByLabelText('Remove landing.html')); expect(input.value).toBe('""'); expect(screen.queryByTestId('staged-attachments')).toBeNull(); }); it('lets the tools panel switch between Official and My plugins', async () => { renderComposer(); fireEvent.click(screen.getByLabelText('Open resources menu')); await waitFor(() => expect(screen.getByText('Community Deck')).toBeTruthy()); await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('Search plugins'))); const menu = screen.getByRole('menu'); expect(within(menu).getByRole('tab', { name: 'Plugins' })).toBeTruthy(); expect(within(menu).getAllByText('Apply').length).toBeGreaterThan(0); expect(screen.queryByText('My Export')).toBeNull(); fireEvent.click(screen.getByText('My plugins')); expect(screen.getByText('My Export')).toBeTruthy(); expect(screen.queryByText('Community Deck')).toBeNull(); fireEvent.change(screen.getByLabelText('Search plugins'), { target: { value: 'private' }, }); expect(screen.getByText('Private export workflow')).toBeTruthy(); }); it('keeps Resources search focused and supports arrow-key selection', async () => { plugins = [ COMMUNITY_PLUGIN, makePlugin({ id: 'brief-writer', title: 'Brief Writer', description: 'Turns source notes into a concise creative brief.', }), ]; renderComposer(); fireEvent.click(screen.getByLabelText('Open resources menu')); const search = await screen.findByLabelText('Search plugins'); await waitFor(() => expect(document.activeElement).toBe(search)); await waitFor(() => expect(screen.getByText('Brief Writer')).toBeTruthy()); expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-0'); fireEvent.keyDown(search, { key: 'ArrowDown' }); expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-1'); expect(screen.getByText('Brief Writer').closest('[role="menuitem"]')?.getAttribute('aria-selected')).toBe('true'); fireEvent.keyDown(search, { key: 'Enter' }); await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); }); it('ranks Resources search results and resets the active option as the query changes', async () => { plugins = [ makePlugin({ id: 'stone-staircase', title: '3D Stone Staircase Evolution Infographic', description: 'Transforms a flat evolutionary timeline into realistic stone stairs.', }), makePlugin({ id: 'airbnb', title: 'Airbnb', description: 'Travel marketplace with warm coral UI.', }), makePlugin({ id: 'airtable', title: 'Airtable', description: 'Spreadsheet-database hybrid.', }), ]; renderComposer(); fireEvent.click(screen.getByLabelText('Open resources menu')); const search = await screen.findByLabelText('Search plugins'); fireEvent.change(search, { target: { value: 'air' } }); await waitFor(() => expect(screen.getByText('Airtable')).toBeTruthy()); const menu = screen.getByRole('menu'); const pluginNames = within(menu) .getAllByRole('menuitem') .map((item) => item.querySelector('strong')?.textContent) .filter(Boolean); expect(pluginNames.slice(0, 3)).toEqual([ 'Airbnb', 'Airtable', '3D Stone Staircase Evolution Infographic', ]); fireEvent.keyDown(search, { key: 'ArrowDown' }); expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-1'); fireEvent.change(search, { target: { value: 'stone' } }); expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-0'); expect( screen.getByText('3D Stone Staircase Evolution Infographic').closest('[role="menuitem"]')?.getAttribute('aria-selected'), ).toBe('true'); }); it('keeps MCP Resources search wired to a stable result container for templates and empty results', async () => { servers = []; templates = [MCP_TEMPLATE]; const onOpenMcpSettings = vi.fn(); renderComposer({ onOpenMcpSettings }); fireEvent.click(screen.getByLabelText('Open resources menu')); fireEvent.click(await screen.findByRole('tab', { name: 'MCP' })); const search = await screen.findByLabelText('Search MCP servers and templates'); await waitFor(() => expect(document.activeElement).toBe(search)); expect(document.getElementById('composer-tools-mcp-results')).toBeTruthy(); expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-mcp-option-0'); fireEvent.change(search, { target: { value: 'figma' } }); expect(screen.getByText('Figma Context')).toBeTruthy(); expect(document.getElementById(search.getAttribute('aria-activedescendant') ?? '')).toBeTruthy(); fireEvent.change(search, { target: { value: 'definitely-missing' } }); expect(screen.getByText('No MCP results for “definitely-missing”.')).toBeTruthy(); expect(document.getElementById(search.getAttribute('aria-activedescendant') ?? '')).toBeTruthy(); fireEvent.keyDown(search, { key: 'Enter' }); expect(onOpenMcpSettings).toHaveBeenCalledTimes(1); }); it('clears absolute anchors when the pet popover switches to fixed positioning', async () => { renderComposer({ petConfig: { adopted: false, enabled: false, petId: 'custom', custom: { name: 'Buddy', glyph: '🐾', accent: '#7c3aed', greeting: 'hi', }, }, onAdoptPet: vi.fn(), onTogglePet: vi.fn(), onOpenPetSettings: vi.fn(), }); fireEvent.click(screen.getByRole('button', { name: 'Pets — wake, tuck, or pick one' })); const menu = screen.getByText('Show pet').closest('.composer-pet-menu') as HTMLElement | null; expect(menu).not.toBeNull(); await waitFor(() => { expect(menu?.style.position).toBe('fixed'); expect(menu?.style.bottom).toBe('auto'); expect(menu?.style.right).toBe('auto'); }); }); });