From 32091146e8182b8e949e3f8f6a9b7ca9f3d7dc77 Mon Sep 17 00:00:00 2001 From: "Roy B.a" Date: Wed, 26 Aug 2026 13:53:17 +0300 Subject: [PATCH 1/4] fix(copilot): render composer popovers above chat in shadow DOM The command, favorite, and mode picker popovers rendered their content inline. In the widget's floating mode the chat panel is itself a transformed Radix popper, which becomes the containing block for the fixed-positioned popovers and breaks floating-ui's scale compensation under host zoom/font-size, throwing them off-screen. Portal each popover into cl_shadowRootElement (the convention the shared ui/* primitives already use) and set an explicit z-index on each picker's content so Radix copies it onto the popper wrapper, keeping them above the z-50 chat. This matches how ui/popover, dropdown, select set z-50 on their own content and avoids a global wrapper override. Co-Authored-By: GitHub Copilot --- .../MessageComposer/CommandPopoverButton.tsx | 91 +++++++------ .../chat/MessageComposer/FavoriteButton.tsx | 127 +++++++++--------- .../chat/MessageComposer/ModePicker.tsx | 103 +++++++------- 3 files changed, 165 insertions(+), 156 deletions(-) diff --git a/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx b/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx index 9d35d4834d..fd6338ed0a 100644 --- a/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx +++ b/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { Popover, PopoverContent, + PopoverPortal, PopoverTrigger } from '@radix-ui/react-popover'; import { every } from 'lodash'; @@ -191,51 +192,53 @@ export const CommandPopoverButton = ({ - - - - - {nonButtonCommands.map((command, index) => ( - handleMouseMove(index)} - onSelect={() => handleCommandSelect(command)} // Direct call for mouse clicks - className="space-x-2" - > - -
-
{command.id}
-
- {command.description} + + + + + + {nonButtonCommands.map((command, index) => ( + handleMouseMove(index)} + onSelect={() => handleCommandSelect(command)} // Direct call for mouse clicks + className="space-x-2" + > + +
+
{command.id}
+
+ {command.description} +
-
- - ))} - - - - + + ))} + + + + +
); diff --git a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx index 6e80a29617..3d498ca4e1 100644 --- a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx +++ b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { Popover, PopoverContent, + PopoverPortal, PopoverTrigger } from '@radix-ui/react-popover'; import { Star, Trash } from 'lucide-react'; @@ -117,69 +118,71 @@ export const FavoriteButton = ({ disabled = false, onSelect }: Props) => { - - - - {favorites.length === 0 ? ( - -
-

- {t('chat.favorites.empty.title')} -

-

- {t('chat.favorites.empty.description')} -

-
-
- ) : ( - - {favorites.map((step) => ( - { - onSelect(step.output); - setOpen(false); - cancelTooltipOpen(); - }} - className="cursor-pointer group" - > -
-
- - {step.output} - - - {new Date(step.createdAt).toLocaleDateString()} - + + + + + {favorites.length === 0 ? ( + +
+

+ {t('chat.favorites.empty.title')} +

+

+ {t('chat.favorites.empty.description')} +

+
+
+ ) : ( + + {favorites.map((step) => ( + { + onSelect(step.output); + setOpen(false); + cancelTooltipOpen(); + }} + className="cursor-pointer group" + > +
+
+ + {step.output} + + + {new Date(step.createdAt).toLocaleDateString()} + +
+
- -
- - ))} - - )} - - - + + ))} + + )} + + + +
); diff --git a/frontend/src/components/chat/MessageComposer/ModePicker.tsx b/frontend/src/components/chat/MessageComposer/ModePicker.tsx index cef142ce1a..bab792a201 100644 --- a/frontend/src/components/chat/MessageComposer/ModePicker.tsx +++ b/frontend/src/components/chat/MessageComposer/ModePicker.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { Popover, PopoverContent, + PopoverPortal, PopoverTrigger } from '@radix-ui/react-popover'; import { ChevronDown, ChevronUp } from 'lucide-react'; @@ -155,57 +156,59 @@ export const ModePicker = ({ - - - - - {options.map((option, index) => ( - handleMouseMove(index)} - onSelect={() => handleOptionSelect(option)} - className={cn( - 'flex items-start gap-2 px-2 py-2 cursor-pointer', - selectedOptionId === option.id && 'bg-accent' - )} - > - {renderIcon( - option.icon, - cn( - '!size-5 mt-0.5 text-muted-foreground flex-shrink-0', - index === selectedIndex && 'text-foreground' - ) - )} -
-
- {option.name} -
- {option.description && ( -
- {option.description} -
+ + + + + + {options.map((option, index) => ( + handleMouseMove(index)} + onSelect={() => handleOptionSelect(option)} + className={cn( + 'flex items-start gap-2 px-2 py-2 cursor-pointer', + selectedOptionId === option.id && 'bg-accent' + )} + > + {renderIcon( + option.icon, + cn( + '!size-5 mt-0.5 text-muted-foreground flex-shrink-0', + index === selectedIndex && 'text-foreground' + ) )} -
-
- ))} -
-
-
-
+
+
+ {option.name} +
+ {option.description && ( +
+ {option.description} +
+ )} +
+
+ ))} +
+
+
+
+ ); From 146433bd7b1434ece2384312e27f1592e8e872ee Mon Sep 17 00:00:00 2001 From: "Roy B.a" Date: Thu, 27 Aug 2026 14:07:51 +0300 Subject: [PATCH 2/4] test(copilot): cover composer popover portaling and stacking Add tests for the three composer pickers verifying each popover is portaled into cl_shadowRootElement and carries the z-[51] stacking class that keeps it above the chat surfaces. Co-Authored-By: GitHub Copilot --- frontend/tests/CommandPopoverButton.spec.tsx | 65 ++++++++++++++++++++ frontend/tests/FavoriteButton.spec.tsx | 52 ++++++++++++++++ frontend/tests/ModePicker.spec.tsx | 59 ++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 frontend/tests/CommandPopoverButton.spec.tsx create mode 100644 frontend/tests/ModePicker.spec.tsx diff --git a/frontend/tests/CommandPopoverButton.spec.tsx b/frontend/tests/CommandPopoverButton.spec.tsx new file mode 100644 index 0000000000..ca06af56a3 --- /dev/null +++ b/frontend/tests/CommandPopoverButton.spec.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { commandsState } from '@chainlit/react-client'; + +import { CommandPopoverButton } from '@/components/chat/MessageComposer/CommandPopoverButton'; + +vi.mock('components/i18n/Translator', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +vi.mock('@chainlit/react-client', async () => { + const { atom } = await import('recoil'); + return { + commandsState: atom({ key: 'commandsState', default: [] }) + }; +}); + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + +const commands = [ + { id: 'Search', description: 'Search the web', icon: 'search', button: false } +]; + +const renderComponent = () => + render( + set(commandsState, commands as any)} + > + + + ); + +describe('CommandPopoverButton — shadow DOM popover positioning', () => { + afterEach(() => { + window.cl_shadowRootElement = undefined; + }); + + it('portals the popover into window.cl_shadowRootElement', () => { + const shadowContainer = document.createElement('div'); + document.body.appendChild(shadowContainer); + window.cl_shadowRootElement = shadowContainer as HTMLDivElement; + + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + expect(shadowContainer.querySelector('#command-popover')).not.toBeNull(); + shadowContainer.remove(); + }); + + it('gives the popover content a stacking z-index above the chat', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + const content = document.getElementById('command-popover'); + expect(content).not.toBeNull(); + expect(content?.classList.contains('z-[51]')).toBe(true); + }); +}); diff --git a/frontend/tests/FavoriteButton.spec.tsx b/frontend/tests/FavoriteButton.spec.tsx index c84c846e8e..c780d99456 100644 --- a/frontend/tests/FavoriteButton.spec.tsx +++ b/frontend/tests/FavoriteButton.spec.tsx @@ -304,3 +304,55 @@ describe('FavoriteButton', () => { expect(screen.getByText('Favorites List')).toBeInTheDocument(); }); }); + +describe('FavoriteButton — shadow DOM popover positioning', () => { + const favorites: IStep[] = [ + { + id: 'msg_1', + output: 'How do I center a div?', + createdAt: new Date('2023-10-01').getTime(), + type: 'assistant_message', + name: 'Assistant' + } + ]; + + const renderComponent = () => + render( + set(favoriteMessagesState, favorites)} + > + + + ); + + beforeEach(() => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + }); + + afterEach(() => { + window.cl_shadowRootElement = undefined; + }); + + it('portals the popover into window.cl_shadowRootElement', () => { + const shadowContainer = document.createElement('div'); + document.body.appendChild(shadowContainer); + window.cl_shadowRootElement = shadowContainer as HTMLDivElement; + + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + expect(shadowContainer).toHaveTextContent('Favorites List'); + shadowContainer.remove(); + }); + + it('gives the popover content a stacking z-index above the chat', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + const content = document.querySelector('.z-\\[51\\]'); + expect(content).not.toBeNull(); + expect(content).toHaveTextContent('Favorites List'); + }); +}); diff --git a/frontend/tests/ModePicker.spec.tsx b/frontend/tests/ModePicker.spec.tsx new file mode 100644 index 0000000000..171a84be08 --- /dev/null +++ b/frontend/tests/ModePicker.spec.tsx @@ -0,0 +1,59 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ModePicker } from '@/components/chat/MessageComposer/ModePicker'; + +vi.mock('@chainlit/react-client', async () => { + const React = await import('react'); + return { + ChainlitContext: React.createContext(undefined) + }; +}); + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + +const mode = { + id: 'model', + name: 'Model', + options: [ + { id: 'fast', name: 'Fast' }, + { id: 'smart', name: 'Smart' } + ] +}; + +const renderComponent = () => + render(); + +describe('ModePicker — shadow DOM popover positioning', () => { + afterEach(() => { + window.cl_shadowRootElement = undefined; + }); + + it('portals the popover into window.cl_shadowRootElement', () => { + const shadowContainer = document.createElement('div'); + document.body.appendChild(shadowContainer); + window.cl_shadowRootElement = shadowContainer as HTMLDivElement; + + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + expect( + shadowContainer.querySelector('#mode-picker-popover-model') + ).not.toBeNull(); + shadowContainer.remove(); + }); + + it('gives the popover content a stacking z-index above the chat', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + const content = document.getElementById('mode-picker-popover-model'); + expect(content).not.toBeNull(); + expect(content?.classList.contains('z-[51]')).toBe(true); + }); +}); From e314146b8c26a66489e099ce4bb4013968243ec3 Mon Sep 17 00:00:00 2001 From: "Roy B.a" Date: Thu, 27 Aug 2026 14:34:21 +0300 Subject: [PATCH 3/4] test(copilot): assert popovers portal into a real shadow root Address review: use attachShadow instead of a plain div and assert the popover lands in the encapsulated shadow tree (not the light DOM), add a standalone document.body fallback test, centralize the ResizeObserver/scrollIntoView shims in setup-tests, and move shadow-host cleanup into afterEach. Co-Authored-By: GitHub Copilot --- frontend/tests/CommandPopoverButton.spec.tsx | 38 ++++++++++--------- frontend/tests/FavoriteButton.spec.tsx | 35 +++++++++-------- frontend/tests/ModePicker.spec.tsx | 40 ++++++++++---------- frontend/tests/setup-tests.ts | 8 ++++ frontend/tests/testUtils.ts | 26 +++++++++++++ 5 files changed, 91 insertions(+), 56 deletions(-) create mode 100644 frontend/tests/testUtils.ts diff --git a/frontend/tests/CommandPopoverButton.spec.tsx b/frontend/tests/CommandPopoverButton.spec.tsx index ca06af56a3..2c421f3680 100644 --- a/frontend/tests/CommandPopoverButton.spec.tsx +++ b/frontend/tests/CommandPopoverButton.spec.tsx @@ -6,6 +6,8 @@ import { commandsState } from '@chainlit/react-client'; import { CommandPopoverButton } from '@/components/chat/MessageComposer/CommandPopoverButton'; +import { cleanupShadowHosts, mountShadowHost } from './testUtils'; + vi.mock('components/i18n/Translator', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); @@ -17,17 +19,12 @@ vi.mock('@chainlit/react-client', async () => { }; }); -global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -}; -window.HTMLElement.prototype.scrollIntoView = vi.fn(); - const commands = [ { id: 'Search', description: 'Search the web', icon: 'search', button: false } ]; +const POPOVER_ID = '#command-popover'; + const renderComponent = () => render( ); describe('CommandPopoverButton — shadow DOM popover positioning', () => { - afterEach(() => { - window.cl_shadowRootElement = undefined; - }); + afterEach(cleanupShadowHosts); + + it('portals the popover into the widget shadow root', () => { + const shadowRoot = mountShadowHost(); - it('portals the popover into window.cl_shadowRootElement', () => { - const shadowContainer = document.createElement('div'); - document.body.appendChild(shadowContainer); - window.cl_shadowRootElement = shadowContainer as HTMLDivElement; + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // Content lives inside the encapsulated shadow tree, not the light DOM. + expect(shadowRoot.querySelector(POPOVER_ID)).not.toBeNull(); + expect(document.body.querySelector(POPOVER_ID)).toBeNull(); + }); + it('falls back to document.body when no shadow root is set (standalone app)', () => { renderComponent(); fireEvent.click(screen.getByRole('button')); - expect(shadowContainer.querySelector('#command-popover')).not.toBeNull(); - shadowContainer.remove(); + expect(document.querySelector(POPOVER_ID)).not.toBeNull(); }); it('gives the popover content a stacking z-index above the chat', () => { renderComponent(); fireEvent.click(screen.getByRole('button')); - const content = document.getElementById('command-popover'); - expect(content).not.toBeNull(); + // jsdom has no layout engine, so we assert the stacking class Radix copies + // onto the popper wrapper rather than computed geometry (covered by e2e). + const content = document.querySelector(POPOVER_ID); expect(content?.classList.contains('z-[51]')).toBe(true); }); }); diff --git a/frontend/tests/FavoriteButton.spec.tsx b/frontend/tests/FavoriteButton.spec.tsx index c780d99456..e538d23c8d 100644 --- a/frontend/tests/FavoriteButton.spec.tsx +++ b/frontend/tests/FavoriteButton.spec.tsx @@ -10,6 +10,8 @@ import { import { FavoriteButton } from '@/components/chat/MessageComposer/FavoriteButton'; +import { cleanupShadowHosts, mountShadowHost } from './testUtils'; + const toggleMessageFavoriteMock = vi.fn(); vi.mock('@/components/i18n/Translator', () => ({ @@ -42,14 +44,6 @@ vi.mock('@chainlit/react-client', async () => { }; }); -global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -}; - -window.HTMLElement.prototype.scrollIntoView = vi.fn(); - describe('FavoriteButton', () => { const mockOnSelect = vi.fn(); @@ -331,28 +325,33 @@ describe('FavoriteButton — shadow DOM popover positioning', () => { }); }); - afterEach(() => { - window.cl_shadowRootElement = undefined; - }); + afterEach(cleanupShadowHosts); - it('portals the popover into window.cl_shadowRootElement', () => { - const shadowContainer = document.createElement('div'); - document.body.appendChild(shadowContainer); - window.cl_shadowRootElement = shadowContainer as HTMLDivElement; + it('portals the popover into the widget shadow root', () => { + const shadowRoot = mountShadowHost(); renderComponent(); fireEvent.click(screen.getByRole('button')); - expect(shadowContainer).toHaveTextContent('Favorites List'); - shadowContainer.remove(); + // Content lives inside the encapsulated shadow tree, not the light DOM. + expect(shadowRoot.textContent).toContain('Favorites List'); + expect(document.body.textContent).not.toContain('Favorites List'); + }); + + it('falls back to document.body when no shadow root is set (standalone app)', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + expect(screen.getByText('Favorites List')).toBeInTheDocument(); }); it('gives the popover content a stacking z-index above the chat', () => { renderComponent(); fireEvent.click(screen.getByRole('button')); + // jsdom has no layout engine, so we assert the stacking class Radix copies + // onto the popper wrapper rather than computed geometry (covered by e2e). const content = document.querySelector('.z-\\[51\\]'); - expect(content).not.toBeNull(); expect(content).toHaveTextContent('Favorites List'); }); }); diff --git a/frontend/tests/ModePicker.spec.tsx b/frontend/tests/ModePicker.spec.tsx index 171a84be08..03e5999109 100644 --- a/frontend/tests/ModePicker.spec.tsx +++ b/frontend/tests/ModePicker.spec.tsx @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ModePicker } from '@/components/chat/MessageComposer/ModePicker'; +import { cleanupShadowHosts, mountShadowHost } from './testUtils'; + vi.mock('@chainlit/react-client', async () => { const React = await import('react'); return { @@ -10,13 +12,6 @@ vi.mock('@chainlit/react-client', async () => { }; }); -global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -}; -window.HTMLElement.prototype.scrollIntoView = vi.fn(); - const mode = { id: 'model', name: 'Model', @@ -26,34 +21,39 @@ const mode = { ] }; +const POPOVER_ID = '#mode-picker-popover-model'; + const renderComponent = () => render(); describe('ModePicker — shadow DOM popover positioning', () => { - afterEach(() => { - window.cl_shadowRootElement = undefined; - }); + afterEach(cleanupShadowHosts); - it('portals the popover into window.cl_shadowRootElement', () => { - const shadowContainer = document.createElement('div'); - document.body.appendChild(shadowContainer); - window.cl_shadowRootElement = shadowContainer as HTMLDivElement; + it('portals the popover into the widget shadow root', () => { + const shadowRoot = mountShadowHost(); + + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // Content lives inside the encapsulated shadow tree, not the light DOM. + expect(shadowRoot.querySelector(POPOVER_ID)).not.toBeNull(); + expect(document.body.querySelector(POPOVER_ID)).toBeNull(); + }); + it('falls back to document.body when no shadow root is set (standalone app)', () => { renderComponent(); fireEvent.click(screen.getByRole('button')); - expect( - shadowContainer.querySelector('#mode-picker-popover-model') - ).not.toBeNull(); - shadowContainer.remove(); + expect(document.querySelector(POPOVER_ID)).not.toBeNull(); }); it('gives the popover content a stacking z-index above the chat', () => { renderComponent(); fireEvent.click(screen.getByRole('button')); - const content = document.getElementById('mode-picker-popover-model'); - expect(content).not.toBeNull(); + // jsdom has no layout engine, so we assert the stacking class Radix copies + // onto the popper wrapper rather than computed geometry (covered by e2e). + const content = document.querySelector(POPOVER_ID); expect(content?.classList.contains('z-[51]')).toBe(true); }); }); diff --git a/frontend/tests/setup-tests.ts b/frontend/tests/setup-tests.ts index 154169172a..d950fcf326 100644 --- a/frontend/tests/setup-tests.ts +++ b/frontend/tests/setup-tests.ts @@ -7,6 +7,14 @@ expect.extend(matchers); // Mock URL.createObjectURL global.URL.createObjectURL = vi.fn(); +// Radix popovers/commands rely on these DOM APIs that jsdom doesn't implement. +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + // Polyfill DOMMatrix for pdfjs-dist which requires it at import time in JSDOM if (typeof globalThis.DOMMatrix === 'undefined') { globalThis.DOMMatrix = class DOMMatrix { diff --git a/frontend/tests/testUtils.ts b/frontend/tests/testUtils.ts new file mode 100644 index 0000000000..d450c92213 --- /dev/null +++ b/frontend/tests/testUtils.ts @@ -0,0 +1,26 @@ +/** + * Mounts a real (open) shadow root and points window.cl_shadowRootElement at a + * div inside it, mirroring how the Copilot widget mounts. Returns the shadow + * root so tests can assert popovers are portaled into the encapsulated tree. + */ +const shadowHosts: HTMLElement[] = []; + +export function mountShadowHost(): ShadowRoot { + const host = document.createElement('div'); + document.body.appendChild(host); + shadowHosts.push(host); + + const shadowRoot = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + shadowRoot.appendChild(container); + window.cl_shadowRootElement = container as HTMLDivElement; + + return shadowRoot; +} + +/** Removes any mounted shadow hosts and clears the global. Safe to run in afterEach. */ +export function cleanupShadowHosts(): void { + shadowHosts.forEach((host) => host.remove()); + shadowHosts.length = 0; + window.cl_shadowRootElement = undefined; +} From 32d595a8c28750b3caf239afc31bc544a4fffbb4 Mon Sep 17 00:00:00 2001 From: "Roy B.a" Date: Thu, 27 Aug 2026 14:48:38 +0300 Subject: [PATCH 4/4] test(copilot): register shadow-host cleanup globally Address review: move cleanupShadowHosts into setup-tests' global afterEach so mountShadowHost consumers can't leak the DOM host or the cl_shadowRootElement global, removing the per-spec afterEach boilerplate. Co-Authored-By: GitHub Copilot --- frontend/tests/CommandPopoverButton.spec.tsx | 6 ++---- frontend/tests/FavoriteButton.spec.tsx | 4 +--- frontend/tests/ModePicker.spec.tsx | 6 ++---- frontend/tests/setup-tests.ts | 3 +++ frontend/tests/testUtils.ts | 7 ++++--- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/frontend/tests/CommandPopoverButton.spec.tsx b/frontend/tests/CommandPopoverButton.spec.tsx index 2c421f3680..e4d808bda2 100644 --- a/frontend/tests/CommandPopoverButton.spec.tsx +++ b/frontend/tests/CommandPopoverButton.spec.tsx @@ -1,12 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { commandsState } from '@chainlit/react-client'; import { CommandPopoverButton } from '@/components/chat/MessageComposer/CommandPopoverButton'; -import { cleanupShadowHosts, mountShadowHost } from './testUtils'; +import { mountShadowHost } from './testUtils'; vi.mock('components/i18n/Translator', () => ({ useTranslation: () => ({ t: (key: string) => key }) @@ -35,8 +35,6 @@ const renderComponent = () => ); describe('CommandPopoverButton — shadow DOM popover positioning', () => { - afterEach(cleanupShadowHosts); - it('portals the popover into the widget shadow root', () => { const shadowRoot = mountShadowHost(); diff --git a/frontend/tests/FavoriteButton.spec.tsx b/frontend/tests/FavoriteButton.spec.tsx index e538d23c8d..6964554d99 100644 --- a/frontend/tests/FavoriteButton.spec.tsx +++ b/frontend/tests/FavoriteButton.spec.tsx @@ -10,7 +10,7 @@ import { import { FavoriteButton } from '@/components/chat/MessageComposer/FavoriteButton'; -import { cleanupShadowHosts, mountShadowHost } from './testUtils'; +import { mountShadowHost } from './testUtils'; const toggleMessageFavoriteMock = vi.fn(); @@ -325,8 +325,6 @@ describe('FavoriteButton — shadow DOM popover positioning', () => { }); }); - afterEach(cleanupShadowHosts); - it('portals the popover into the widget shadow root', () => { const shadowRoot = mountShadowHost(); diff --git a/frontend/tests/ModePicker.spec.tsx b/frontend/tests/ModePicker.spec.tsx index 03e5999109..7ba0f4757a 100644 --- a/frontend/tests/ModePicker.spec.tsx +++ b/frontend/tests/ModePicker.spec.tsx @@ -1,9 +1,9 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ModePicker } from '@/components/chat/MessageComposer/ModePicker'; -import { cleanupShadowHosts, mountShadowHost } from './testUtils'; +import { mountShadowHost } from './testUtils'; vi.mock('@chainlit/react-client', async () => { const React = await import('react'); @@ -27,8 +27,6 @@ const renderComponent = () => render(); describe('ModePicker — shadow DOM popover positioning', () => { - afterEach(cleanupShadowHosts); - it('portals the popover into the widget shadow root', () => { const shadowRoot = mountShadowHost(); diff --git a/frontend/tests/setup-tests.ts b/frontend/tests/setup-tests.ts index d950fcf326..eac3e7368d 100644 --- a/frontend/tests/setup-tests.ts +++ b/frontend/tests/setup-tests.ts @@ -2,6 +2,8 @@ import matchers from '@testing-library/jest-dom/matchers'; import { cleanup } from '@testing-library/react'; import { afterEach, expect, vi } from 'vitest'; +import { cleanupShadowHosts } from './testUtils'; + expect.extend(matchers); // Mock URL.createObjectURL @@ -57,4 +59,5 @@ if (typeof globalThis.DOMMatrix === 'undefined') { afterEach(() => { cleanup(); + cleanupShadowHosts(); }); diff --git a/frontend/tests/testUtils.ts b/frontend/tests/testUtils.ts index d450c92213..b9a85c5edb 100644 --- a/frontend/tests/testUtils.ts +++ b/frontend/tests/testUtils.ts @@ -1,10 +1,11 @@ +const shadowHosts: HTMLElement[] = []; + /** * Mounts a real (open) shadow root and points window.cl_shadowRootElement at a * div inside it, mirroring how the Copilot widget mounts. Returns the shadow * root so tests can assert popovers are portaled into the encapsulated tree. + * Cleanup is handled globally by setup-tests' afterEach — no per-spec wiring needed. */ -const shadowHosts: HTMLElement[] = []; - export function mountShadowHost(): ShadowRoot { const host = document.createElement('div'); document.body.appendChild(host); @@ -18,7 +19,7 @@ export function mountShadowHost(): ShadowRoot { return shadowRoot; } -/** Removes any mounted shadow hosts and clears the global. Safe to run in afterEach. */ +/** Removes any mounted shadow hosts and clears the global. Run once from setup-tests' afterEach. */ export function cleanupShadowHosts(): void { shadowHosts.forEach((host) => host.remove()); shadowHosts.length = 0;