From 3b4b46ea98e34e598fb7d313a34faa4896c71a6d Mon Sep 17 00:00:00 2001 From: bruugey Date: Tue, 19 Aug 2025 12:23:33 -0400 Subject: [PATCH 1/9] Adds test harnesses to SplitButton component --- packages/split-button/package.json | 3 +- .../src/SplitButton/SplitButton.spec.tsx | 842 +++++++++++------- packages/split-button/src/index.ts | 3 + .../src/testing/getTestUtils.spec.tsx | 122 +++ .../split-button/src/testing/getTestUtils.ts | 132 +++ .../src/testing/getTestUtils.types.ts | 27 + packages/split-button/src/testing/index.ts | 2 + packages/split-button/tsconfig.json | 3 + 8 files changed, 804 insertions(+), 330 deletions(-) create mode 100644 packages/split-button/src/testing/getTestUtils.spec.tsx create mode 100644 packages/split-button/src/testing/getTestUtils.ts create mode 100644 packages/split-button/src/testing/getTestUtils.types.ts create mode 100644 packages/split-button/src/testing/index.ts diff --git a/packages/split-button/package.json b/packages/split-button/package.json index 0a97e5afeb..951e61eaa6 100644 --- a/packages/split-button/package.json +++ b/packages/split-button/package.json @@ -39,6 +39,7 @@ "@leafygreen-ui/leafygreen-provider": "workspace:^" }, "devDependencies": { - "@lg-tools/build": "workspace:^" + "@lg-tools/build": "workspace:^", + "@lg-tools/test-harnesses": "workspace:^" } } diff --git a/packages/split-button/src/SplitButton/SplitButton.spec.tsx b/packages/split-button/src/SplitButton/SplitButton.spec.tsx index 03d512cb07..d110ba7235 100644 --- a/packages/split-button/src/SplitButton/SplitButton.spec.tsx +++ b/packages/split-button/src/SplitButton/SplitButton.spec.tsx @@ -1,253 +1,520 @@ import React, { createRef } from 'react'; import { - fireEvent, - getAllByRole as globalGetAllByRole, render, waitFor, waitForElementToBeRemoved, - within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; -import { Optional } from '@leafygreen-ui/lib'; -import { getLgIds as getMenuLgIds, MenuItem } from '@leafygreen-ui/menu'; -import { InferredPolymorphicPropsWithRef } from '@leafygreen-ui/polymorphic'; +import { Size } from '@leafygreen-ui/button'; +import { MenuItem } from '@leafygreen-ui/menu'; import { RenderMode } from '@leafygreen-ui/popover'; -import { getLgIds } from '../utils/getLgIds'; - -import { MenuItemsType, SplitButtonProps } from './SplitButton.types'; -import { SplitButton } from '.'; - -const lgIds = getLgIds(); -const menuLgIds = getMenuLgIds(lgIds.menu); - -const getMenuItems = (): MenuItemsType => { - return [ - - Menu Item With Description - , - - Disabled Menu Item - , - Menu Item, - Another Menu Item, - ]; +import { getTestUtils } from '../testing'; + +import { SplitButton } from './SplitButton'; +import { Align, Justify, Variant } from './SplitButton.types'; + +// Test data +const defaultMenuItems = [ + Edit, + Duplicate, + + Delete + , + + Archive + , +]; + +const basicProps = { + label: 'Primary Action', + menuItems: defaultMenuItems, }; -const defaultProps = { - label: 'Button Label', - menuItems: getMenuItems(), -}; - -type RenderSplitButtonProps = Optional< - InferredPolymorphicPropsWithRef<'button', SplitButtonProps>, - keyof typeof defaultProps ->; - -function renderSplitButton(props: RenderSplitButtonProps = {}) { - const renderResult = render(); - const wrapper = renderResult.getByTestId(lgIds.root); - const primaryButton = renderResult.getByTestId(lgIds.button); - const menuTrigger = renderResult.getByTestId(lgIds.trigger); - - /** - * Since menu elements won't exist until component is interacted with, - * call this after opening the menu. - * @returns Object of menu elements - */ - // TODO: Consolidate with Menu component util - async function findMenuElements(): Promise<{ - menuEl: HTMLElement | null; - menuItemElements: Array; - }> { - const menuEl = await renderResult.findByTestId(menuLgIds.root); - const menuItemElements = await within(menuEl).findAllByRole('menuitem'); - - return { - menuEl, - menuItemElements, - }; - } - - /** - * Opens the menu, and manually fires transition events - */ - async function openMenu(options?: { withKeyboard: boolean }) { - if (options?.withKeyboard) { - menuTrigger.focus(); - userEvent.keyboard('{enter}'); - } else { - userEvent.click(menuTrigger); - } - - const menuElements = await findMenuElements(); - fireEvent.transitionEnd(menuElements.menuEl as Element); // JSDOM does not automatically fire these events - return menuElements; - } +// Helper function to render SplitButton with getTestUtils +function renderSplitButtonWithUtils(props: any = {}) { + const allProps = { ...basicProps, ...props }; + const renderResult = render(); + const utils = getTestUtils(props['data-lgid']); return { ...renderResult, - primaryButton, - menuTrigger, - wrapper, - findMenuElements, - openMenu, + utils, }; } -describe('packages/split-button', () => { - describe('a11y', () => { - test('does not have basic accessibility issues', async () => { - const { container } = renderSplitButton({}); +describe('SplitButton - Comprehensive Test Suite', () => { + describe('Accessibility', () => { + test('has no accessibility violations', async () => { + const { container } = renderSplitButtonWithUtils(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + test('has no accessibility violations when menu is open', async () => { + const { container, utils } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + const results = await axe(container); expect(results).toHaveNoViolations(); }); + + test('has proper ARIA attributes', () => { + const { utils } = renderSplitButtonWithUtils(); + + const button = utils.getButton(); + const trigger = utils.getTrigger(); + + expect(button).toHaveAttribute('type', 'button'); + expect(trigger).toHaveAttribute('aria-haspopup', 'true'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + test('updates aria-expanded when menu opens/closes', async () => { + const { utils } = renderSplitButtonWithUtils(); + const trigger = utils.getTrigger(); + + // Initially closed + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + // Open menu + await utils.openMenu(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); }); - describe('Wrapper container', () => { - test('renders correct className', () => { - const { wrapper } = renderSplitButton({ - className: 'split-button-class', + describe('Rendering', () => { + test('renders primary button with correct label', () => { + const { utils } = renderSplitButtonWithUtils({ label: 'Custom Label' }); + + const button = utils.getButton(); + expect(button).toHaveTextContent('Custom Label'); + expect(button).toBeVisible(); + }); + + test('renders menu trigger button', () => { + const { utils } = renderSplitButtonWithUtils(); + + const trigger = utils.getTrigger(); + expect(trigger).toBeVisible(); + expect(trigger).toHaveAttribute('aria-haspopup', 'true'); + }); + + test('renders with custom className', () => { + const { utils } = renderSplitButtonWithUtils({ + className: 'custom-split-button', + }); + + const root = utils.getRoot(); + expect(root).toHaveClass('custom-split-button'); + }); + + test('renders with custom lgId', () => { + const { utils } = renderSplitButtonWithUtils({ + 'data-lgid': 'custom-split-button', }); - expect(wrapper.classList.contains('split-button-class')).toBe(true); + const root = utils.getRoot(); + expect(root).toHaveAttribute('data-lgid', 'custom-split-button'); + }); + + test('accepts a portalRef', () => { + const portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + const portalRef = createRef(); + renderSplitButtonWithUtils({ + open: true, + portalContainer, + portalRef, + renderMode: RenderMode.Portal, + }); + expect(portalRef.current).toBeDefined(); + expect(portalRef.current).toBe(portalContainer); }); }); - describe('Primary button', () => { - test('renders the correct label', () => { - const { primaryButton } = renderSplitButton({}); + describe('Menu Functionality', () => { + test('opens menu when trigger is clicked', async () => { + const { utils } = renderSplitButtonWithUtils(); - expect(primaryButton.textContent).toBe(defaultProps.label); + // Menu should not be visible initially + expect(utils.queryMenu()).not.toBeInTheDocument(); + + // Open menu + await utils.openMenu(); + + // Menu should now be visible + expect(utils.getMenu()).toBeInTheDocument(); }); - describe('when disabled', () => { - test('is aria-disabled when disabled is true', () => { - const { primaryButton } = renderSplitButton({ - disabled: true, - }); + test('opens menu when trigger is activated with keyboard', async () => { + const { utils } = renderSplitButtonWithUtils(); - expect(primaryButton.getAttribute('aria-disabled')).toBe('true'); - }); + // Open menu with keyboard + await utils.openMenu({ withKeyboard: true }); - test('click handler does not fire when disabled', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ - onClick, - disabled: true, - }); + // Menu should be visible + expect(utils.getMenu()).toBeInTheDocument(); + }); + + test('displays correct number of menu items', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + expect(menuItems).toHaveLength(4); // 4 items as defined in defaultMenuItems + }); + + test('menu items have correct content', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + expect(menuItems[0]).toHaveTextContent('Edit'); + expect(menuItems[1]).toHaveTextContent('Duplicate'); + expect(menuItems[2]).toHaveTextContent('Delete'); + expect(menuItems[3]).toHaveTextContent('Archive'); + }); + + test('disabled menu items are properly marked', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + const archiveItem = menuItems[3]; // Archive is disabled + expect(archiveItem).toHaveAttribute('aria-disabled', 'true'); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('closes menu when clicking outside', async () => { + const { utils } = renderSplitButtonWithUtils(); + + // Open menu + await utils.openMenu(); + expect(utils.getMenu()).toBeInTheDocument(); + + // Close menu + // Works correctly in the browser + // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-1453666401 - this needs v13 of testing-library + // TODO: This is not triggered so the test fails + await utils.closeMenu(); + expect(utils.queryMenu()).not.toBeInTheDocument(); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('closes menu when pressing Escape', async () => { + const { utils } = renderSplitButtonWithUtils(); - userEvent.click(primaryButton); - expect(onClick).not.toHaveBeenCalled(); + // Open menu + await utils.openMenu(); + expect(utils.getMenu()).toBeInTheDocument(); + + // Close with keyboard + // Works correctly in the browser + // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-1453666401 - this needs v13 of testing-library + // TODO: This is not triggered so the test fails + await utils.closeMenu({ withKeyboard: true }); + expect(utils.queryMenu()).not.toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + test('primary button click fires onClick handler', () => { + const onClickSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ onClick: onClickSpy }); + + const button = utils.getButton(); + userEvent.click(button); + + expect(onClickSpy).toHaveBeenCalledTimes(1); + }); + + test('menu item selection fires onChange handler', async () => { + const onChangeSpy = jest.fn(); + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + onChange: onChangeSpy, }); - test('click handler does not fire when disabled and rendered as an anchor', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ - onClick, - disabled: true, - as: 'a', - }); + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + userEvent.click(menuItems[0]); // Click "Edit" + + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); - userEvent.click(primaryButton); - expect(onClick).not.toHaveBeenCalled(); + test('disabled menu items do not trigger onChange', async () => { + const onChangeSpy = jest.fn(); + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + onChange: onChangeSpy, }); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + userEvent.click(menuItems[3]); // Click disabled "Archive" item + + expect(onChangeSpy).not.toHaveBeenCalled(); }); - test('fires onClick handler once when clicked', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ - onClick, + test('trigger click fires onTriggerClick handler when provided', async () => { + const onTriggerClickSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ + onTriggerClick: onTriggerClickSpy, }); - userEvent.click(primaryButton); - expect(onClick).toHaveBeenCalledTimes(1); + const trigger = utils.getTrigger(); + userEvent.click(trigger); + + expect(onTriggerClickSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Disabled State', () => { + test('identifies disabled state correctly', () => { + const { utils } = renderSplitButtonWithUtils({ disabled: true }); + expect(utils.isDisabled()).toBeTruthy(); + expect(utils.getButton().getAttribute('aria-disabled')).toBeTruthy(); }); - test('does not fire onClick handler when disabled', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ + test('disabled button does not respond to clicks', () => { + const onClickSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ disabled: true, - onClick, + onClick: onClickSpy, }); - userEvent.click(primaryButton); - expect(onClick).toHaveBeenCalledTimes(0); + const button = utils.getButton(); + userEvent.click(button); + + expect(onClickSpy).not.toHaveBeenCalled(); + }); + + test('disabled trigger does not open menu', async () => { + const { utils } = renderSplitButtonWithUtils({ disabled: true }); + + const trigger = utils.getTrigger(); + userEvent.click(trigger); + + // Menu should not appear + expect(utils.queryMenu()).not.toBeInTheDocument(); + }); + + test('disabled state affects both button and trigger', () => { + const { utils } = renderSplitButtonWithUtils({ disabled: true }); + + const button = utils.getButton(); + const trigger = utils.getTrigger(); + + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(trigger).toHaveAttribute('aria-disabled', 'true'); }); }); - describe('Menu', () => { - test('trigger opens the menu when clicked', () => { - const { menuTrigger, getByTestId } = renderSplitButton({}); + describe('Variants and Styling', () => { + test.each(Object.values(Variant))( + 'renders %s variant correctly', + variant => { + const { utils } = renderSplitButtonWithUtils({ variant }); + + const button = utils.getButton(); + expect(button).toBeInTheDocument(); + expect(button).toBeVisible(); + }, + ); + + test.each(Object.values(Size))('renders %s size correctly', size => { + const { utils } = renderSplitButtonWithUtils({ size }); + + const button = utils.getButton(); + expect(button).toBeInTheDocument(); + expect(button).toBeVisible(); + }); - userEvent.click(menuTrigger); + test('applies dark mode correctly', () => { + const { utils } = renderSplitButtonWithUtils({ darkMode: true }); - const menu = getByTestId(menuLgIds.root); + const button = utils.getButton(); + expect(button).toBeInTheDocument(); + }); + }); + + describe('Menu Positioning', () => { + test.each(Object.values(Align))('handles %s alignment', async align => { + const { utils } = renderSplitButtonWithUtils({ align }); + + await utils.openMenu(); + + const menu = utils.getMenu(); expect(menu).toBeInTheDocument(); }); - test('disabled trigger does not open the menu when clicked', () => { - const { menuTrigger, queryByTestId } = renderSplitButton({ - disabled: true, + test.each(Object.values(Justify))( + 'handles %s justification', + async justify => { + const { utils } = renderSplitButtonWithUtils({ justify }); + + await utils.openMenu(); + + const menu = utils.getMenu(); + expect(menu).toBeInTheDocument(); + }, + ); + }); + + describe('Polymorphic Behavior', () => { + test('renders as anchor when href is provided', () => { + const { utils } = renderSplitButtonWithUtils({ + href: 'https://example.com', }); - userEvent.click(menuTrigger); + const button = utils.getButton(); + expect(button.tagName.toLowerCase()).toBe('a'); + expect(button).toHaveAttribute('href', 'https://example.com'); + }); + + test('renders as button by default', () => { + const { utils } = renderSplitButtonWithUtils(); + + const button = utils.getButton(); + expect(button.tagName.toLowerCase()).toBe('button'); + expect(button).toHaveAttribute('type', 'button'); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('Accepts string as `as` prop', () => { + ; + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('Accepts component as `as` prop', () => { + const As = ({ children }: { children: React.ReactNode }) => ( + <>{children} + ); + render( + , + ); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('types', () => { + const AnchorLikeWrapper = (props: JSX.IntrinsicElements['a']) => { + return content; + }; - const menu = queryByTestId(menuLgIds.root); - expect(menu).not.toBeInTheDocument(); + <> + + + + + ; }); + }); + + describe('Controlled State', () => { + test('respects controlled open state', async () => { + const setOpenSpy = jest.fn(); + const { utils, rerender } = renderSplitButtonWithUtils({ + open: false, + setOpen: setOpenSpy, + }); - test('has correct menu items', () => { - const { menuTrigger, getByTestId } = renderSplitButton({}); + // Initially closed + expect(utils.queryMenu()).not.toBeInTheDocument(); - userEvent.click(menuTrigger); + // Rerender with open=true + rerender( + , + ); - const menu = getByTestId(menuLgIds.root); - expect(menu.childElementCount).toEqual(4); + // Should now show menu + expect(utils.getMenu()).toBeInTheDocument(); }); - test('accepts a portalRef', () => { - const portalContainer = document.createElement('div'); - document.body.appendChild(portalContainer); - const portalRef = createRef(); - renderSplitButton({ - open: true, - portalContainer, - portalRef, - renderMode: RenderMode.Portal, + test('calls setOpen when trigger is clicked in controlled mode', () => { + const setOpenSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ + open: false, + setOpen: setOpenSpy, }); - expect(portalRef.current).toBeDefined(); - expect(portalRef.current).toBe(portalContainer); + + const trigger = utils.getTrigger(); + userEvent.click(trigger); + + expect(setOpenSpy).toHaveBeenCalledTimes(1); }); + }); + + describe('Edge Cases', () => { + test('handles empty menu items array', () => { + const { utils } = renderSplitButtonWithUtils({ menuItems: [] }); - describe('managed', () => { - test('calls setOpen when triggered', () => { - const setOpen = jest.fn(); - const { menuTrigger } = renderSplitButton({ open: false, setOpen }); + expect(utils.getButton()).toBeInTheDocument(); + expect(utils.getTrigger()).toBeInTheDocument(); + }); - userEvent.click(menuTrigger); - expect(setOpen).toHaveBeenCalledTimes(1); + test('handles single menu item', async () => { + const singleItem = [Single Item]; + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems: singleItem, }); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + expect(menuItems).toHaveLength(1); + expect(menuItems[0]).toHaveTextContent('Single Item'); }); - }); - describe('MenuItem', () => { - test('click triggers onChange callback', () => { - const onChange = jest.fn(); - const { menuTrigger, getByTestId } = renderSplitButton({ onChange }); + test('handles long button labels', () => { + const longLabel = + 'This is a very long button label that might cause layout issues'; + const { utils } = renderSplitButtonWithUtils({ label: longLabel }); - userEvent.click(menuTrigger); + const button = utils.getButton(); + expect(button).toHaveTextContent(longLabel); + }); + + test('handles special characters in labels', () => { + const specialLabel = 'Action <>&"\''; + const { utils } = renderSplitButtonWithUtils({ label: specialLabel }); - const menu = getByTestId(menuLgIds.root); - const options = globalGetAllByRole(menu, 'menuitem'); - userEvent.click(options[0]); - expect(onChange).toHaveBeenCalledTimes(1); + const button = utils.getButton(); + expect(button).toHaveTextContent(specialLabel); }); }); - // TODO: Consolidate tests with Menu component describe('Keyboard Interaction', () => { type CloseKeys = 'esc' | 'tab'; const closeKeys: Array> = [['esc'], ['tab']]; @@ -262,85 +529,79 @@ describe('packages/split-button', () => { describe.each(closeKeys)('%s key', key => { test('Closes menu', async () => { - const { openMenu } = renderSplitButton({}); - const { menuEl } = await openMenu(); + const { utils } = renderSplitButtonWithUtils({}); + + await utils.openMenu(); + const menuEl = utils.getMenu(); userEventInteraction(menuEl!, key); await waitForElementToBeRemoved(menuEl); expect(menuEl).not.toBeInTheDocument(); }); + test('Returns focus to trigger {renderMode: "portal"}', async () => { - const { openMenu, menuTrigger } = renderSplitButton({ + const { utils } = renderSplitButtonWithUtils({ renderMode: 'portal', }); - const { menuEl } = await openMenu(); + + await utils.openMenu(); + const menuEl = utils.getMenu(); + const trigger = utils.getTrigger(); userEventInteraction(menuEl!, key); await waitForElementToBeRemoved(menuEl); - expect(menuTrigger).toHaveFocus(); + expect(trigger).toHaveFocus(); }); test('Returns focus to trigger {renderMode: "inline"}', async () => { - const { openMenu, menuTrigger } = renderSplitButton({ + const { utils } = renderSplitButtonWithUtils({ renderMode: 'inline', }); - const { menuEl } = await openMenu(); + + await utils.openMenu(); + const menuEl = utils.getMenu(); + const trigger = utils.getTrigger(); userEventInteraction(menuEl!, key); await waitForElementToBeRemoved(menuEl); - expect(menuTrigger).toHaveFocus(); + expect(trigger).toHaveFocus(); }); }); + }); - type SelectionKeys = 'enter' | 'space'; - const selectionKeys: Array> = [['enter'], ['space']]; - - describe.each(selectionKeys)('%s key', key => { - const onClick = jest.fn(); - const menuItems = [ - - Menu Item With Description - , - - Disabled Menu Item - , - ]; + type SelectionKeys = 'enter' | 'space'; + const selectionKeys: Array> = [['enter'], ['space']]; + + describe.each(selectionKeys)('%s key', key => { + const onClick = jest.fn(); + const menuItems = [ + + Menu Item With Description + , + + Disabled Menu Item + , + ]; + + afterEach(() => { + onClick.mockReset(); + }); - afterEach(() => { - onClick.mockReset(); + test('Fires the click handler of the highlighted item', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); - test('Fires the click handler of the highlighted item', async () => { - const { openMenu } = renderSplitButton({ - menuItems, - }); - const { menuItemElements } = await openMenu({ - withKeyboard: true, - }); - await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); - - userEvent.type(menuItemElements?.[0]!, `{${key}}`); - expect(onClick).toHaveBeenCalled(); + await utils.openMenu({ + withKeyboard: true, }); - // eslint-disable-next-line jest/no-disabled-tests - test.skip('Closes the menu', async () => { - // Works correctly in the browser - // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-1453666401 - this needs v13 of testing-library - // TODO: This is not triggered so the test fails - const { openMenu, menuTrigger } = renderSplitButton({ - menuItems, - }); - const { menuEl, menuItemElements } = await openMenu(); - userEvent.type(menuItemElements?.[0]!, `{${key}}`); + const menuItemElements = getAllByRole('menuitem'); - expect(menuTrigger).toHaveFocus(); - await waitForElementToBeRemoved(menuEl); - }); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); + + userEvent.type(menuItemElements?.[0]!, `{${key}}`); + expect(onClick).toHaveBeenCalled(); }); describe('Arrow keys', () => { @@ -353,10 +614,14 @@ describe('packages/split-button', () => { describe('Down arrow', () => { test('highlights the next option in the menu', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); userEvent.type(menuEl!, '{arrowdown}'); @@ -364,10 +629,14 @@ describe('packages/split-button', () => { }); test('cycles highlight to the top', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); for (let i = 0; i < menuItemElements.length; i++) { @@ -380,10 +649,14 @@ describe('packages/split-button', () => { describe('Up arrow', () => { test('highlights the previous option in the menu', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); userEvent.type(menuEl!, '{arrowdown}'); @@ -394,10 +667,14 @@ describe('packages/split-button', () => { }); test('cycles highlight to the bottom', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); const lastOption = menuItemElements[menuItemElements.length - 1]; @@ -407,97 +684,4 @@ describe('packages/split-button', () => { }); }); }); - - /* eslint-disable jest/no-disabled-tests */ - describe('Types behave as expected', () => { - test.skip('Accepts base props', () => { - <> - - Menu Item, - - Disabled Menu Item - , - - , Menu Item With Description - , - ]} - /> - {}} - disabled={true} - size="default" - variant="default" - darkMode={true} - align="top" - justify="start" - className="test" - renderMode="portal" - portalContainer={{} as HTMLElement} - scrollContainer={{} as HTMLElement} - portalClassName="classname" - data-testid="test-id" - open={false} - triggerAriaLabel="im the trigger silly" - /> - {/* @ts-expect-error - expects label and menuItems*/} - - {/* @ts-expect-error - expects menuItems */} - - {/* @ts-expect-error - expects label */} - - ; - }); - - test.skip('Accepts string as `as` prop', () => { - ; - }); - - test.skip('Accepts component as `as` prop', () => { - const As = ({ children }: { children: React.ReactNode }) => ( - <>{children} - ); - render( - , - ); - }); - - test.skip('types', () => { - const AnchorLikeWrapper = (props: JSX.IntrinsicElements['a']) => { - return content; - }; - - <> - - - - - ; - }); - }); }); diff --git a/packages/split-button/src/index.ts b/packages/split-button/src/index.ts index 21683a890f..59e5a5eaee 100644 --- a/packages/split-button/src/index.ts +++ b/packages/split-button/src/index.ts @@ -7,3 +7,6 @@ export { Variant, } from './SplitButton'; export { DEFAULT_LGID_ROOT, getLgIds, type GetLgIdsReturnType } from './utils'; + +// Testing utilities +export { getTestUtils, type GetTestUtilsReturnType } from './testing'; diff --git a/packages/split-button/src/testing/getTestUtils.spec.tsx b/packages/split-button/src/testing/getTestUtils.spec.tsx new file mode 100644 index 0000000000..2a03c7b913 --- /dev/null +++ b/packages/split-button/src/testing/getTestUtils.spec.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { MenuItem } from '@leafygreen-ui/menu'; + +import { SplitButton } from '../SplitButton'; + +import { getTestUtils } from './getTestUtils'; + +const menuItems = [ + Menu Item 1, + Menu Item 2, + + Disabled Menu Item + , +]; + +const defaultProps = { + label: 'Test SplitButton', + menuItems, +}; + +describe('packages/split-button/testing/getTestUtils', () => { + describe('getTestUtils', () => { + test('returns all expected utility functions', () => { + render(); + const utils = getTestUtils(); + + // Check all utility functions exist + expect(utils.findRoot).toBeDefined(); + expect(utils.getRoot).toBeDefined(); + expect(utils.queryRoot).toBeDefined(); + + expect(utils.findButton).toBeDefined(); + expect(utils.getButton).toBeDefined(); + expect(utils.queryButton).toBeDefined(); + + expect(utils.findTrigger).toBeDefined(); + expect(utils.getTrigger).toBeDefined(); + expect(utils.queryTrigger).toBeDefined(); + + expect(utils.findMenu).toBeDefined(); + expect(utils.getMenu).toBeDefined(); + expect(utils.queryMenu).toBeDefined(); + + expect(utils.findMenuItems).toBeDefined(); + expect(utils.getMenuItems).toBeDefined(); + expect(utils.queryMenuItems).toBeDefined(); + + expect(utils.isDisabled).toBeDefined(); + expect(utils.openMenu).toBeDefined(); + expect(utils.closeMenu).toBeDefined(); + }); + + test('gets root element correctly', () => { + render(); + const { getRoot } = getTestUtils(); + + const root = getRoot(); + expect(root).toBeInTheDocument(); + expect(root.getAttribute('data-lgid')).toBe('lg-split_button'); + }); + + test('gets button element correctly', () => { + render(); + const { getButton } = getTestUtils(); + + const button = getButton(); + expect(button).toBeInTheDocument(); + expect(button.textContent).toBe('Test SplitButton'); + }); + + test('gets trigger element correctly', () => { + render(); + const { getTrigger } = getTestUtils(); + + const trigger = getTrigger(); + expect(trigger).toBeInTheDocument(); + expect(trigger.getAttribute('aria-haspopup')).toBe('true'); + }); + + test('detects disabled state correctly', () => { + const { rerender } = render(); + const { isDisabled } = getTestUtils(); + + // Initially not disabled + expect(isDisabled()).toBe(false); + + // Rerender with disabled prop + rerender(); + expect(isDisabled()).toBe(true); + }); + + test('opens menu correctly', async () => { + render(); + const { openMenu, findMenu } = getTestUtils(); + + // Menu should not be visible initially + expect( + document.querySelector('[data-lgid="lg-split_button-menu"]'), + ).not.toBeInTheDocument(); + + // Open menu + await openMenu(); + + // Menu should now be visible + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + }); + + test('works with custom lgId', () => { + render( + , + ); + const { getRoot } = getTestUtils('lg-custom-split-button'); + + const root = getRoot(); + expect(root).toBeInTheDocument(); + expect(root.getAttribute('data-lgid')).toBe('lg-custom-split-button'); + }); + }); +}); diff --git a/packages/split-button/src/testing/getTestUtils.ts b/packages/split-button/src/testing/getTestUtils.ts new file mode 100644 index 0000000000..ef7a7c2267 --- /dev/null +++ b/packages/split-button/src/testing/getTestUtils.ts @@ -0,0 +1,132 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; +import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { LgIdString } from '@leafygreen-ui/lib'; +import { getLgIds as getMenuLgIds } from '@leafygreen-ui/menu'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; + +import { GetTestUtilsReturnType } from './getTestUtils.types'; + +export const getTestUtils = ( + lgId: LgIdString = DEFAULT_LGID_ROOT, +): GetTestUtilsReturnType => { + const lgIds = getLgIds(lgId); + const menuLgIds = getMenuLgIds(lgIds.menu); + + // Root/Wrapper utilities + const findRoot = () => findByLgId!(lgIds.root); + const getRoot = () => getByLgId!(lgIds.root); + const queryRoot = () => queryByLgId!(lgIds.root); + + // Primary button utilities + const findButton = () => findByLgId!(lgIds.button); + const getButton = () => getByLgId!(lgIds.button); + const queryButton = () => queryByLgId!(lgIds.button); + + // Menu trigger utilities + const findTrigger = () => findByLgId!(lgIds.trigger); + const getTrigger = () => getByLgId!(lgIds.trigger); + const queryTrigger = () => queryByLgId!(lgIds.trigger); + + // Menu utilities + const findMenu = () => findByLgId!(menuLgIds.root); + const getMenu = () => getByLgId!(menuLgIds.root); + const queryMenu = () => queryByLgId!(menuLgIds.root); + + // Menu items utilities + const findMenuItems = async (findAllByRole?: Function) => { + const menuEl = await findMenu(); + return findAllByRole?.(menuEl, 'menuitem'); + }; + + const getMenuItems = (getAllByRole?: Function) => { + const menuEl = getMenu(); + return getAllByRole?.(menuEl, 'menuitem'); + }; + + const queryMenuItems = (queryAllByRole?: Function) => { + const menuEl = queryMenu(); + if (!menuEl) return []; + return queryAllByRole?.(menuEl, 'menuitem'); + }; + + /** + * Returns the disabled state of the SplitButton. + * Checks both the primary button and trigger button disabled states. + */ + const isDisabled = () => { + const button = getButton(); + const trigger = getTrigger(); + + const buttonDisabled = button.getAttribute('aria-disabled') === 'true'; + const triggerDisabled = trigger.getAttribute('aria-disabled') === 'true'; + + return buttonDisabled && triggerDisabled; + }; + + /** + * Opens the menu by clicking or pressing Enter on the trigger + */ + const openMenu = async (options: { withKeyboard?: boolean } = {}) => { + const trigger = getTrigger(); + + if (options.withKeyboard) { + trigger.focus(); + userEvent.keyboard('{enter}'); + } else { + userEvent.click(trigger); + } + + // Wait for menu to appear + const menuEl = await findMenu(); + + // Manually fire transition events since JSDOM doesn't automatically fire them + fireEvent.transitionEnd(menuEl); + }; + + /** + * Closes the menu by pressing Escape or clicking outside + */ + const closeMenu = async (options: { withKeyboard?: boolean } = {}) => { + if (options.withKeyboard) { + userEvent.keyboard('{escape}'); + } else { + // Click outside the menu to close it + userEvent.click(document.body); + } + + // Wait for menu to disappear - we'll use a try/catch since the menu might not exist + try { + const menuEl = queryMenu(); + + if (menuEl) { + fireEvent.transitionEnd(menuEl); + } + } catch { + // Menu already closed + } + }; + + return { + findRoot, + getRoot, + queryRoot, + findButton, + getButton, + queryButton, + findTrigger, + getTrigger, + queryTrigger, + findMenu, + getMenu, + queryMenu, + findMenuItems, + getMenuItems, + queryMenuItems, + isDisabled, + openMenu, + closeMenu, + }; +}; diff --git a/packages/split-button/src/testing/getTestUtils.types.ts b/packages/split-button/src/testing/getTestUtils.types.ts new file mode 100644 index 0000000000..8331103bbc --- /dev/null +++ b/packages/split-button/src/testing/getTestUtils.types.ts @@ -0,0 +1,27 @@ +import { FormUtils } from '@lg-tools/test-harnesses'; + +export interface GetTestUtilsReturnType { + findRoot: () => Promise; + getRoot: () => T; + queryRoot: () => T | null; + + findButton: () => Promise; + getButton: () => HTMLButtonElement; + queryButton: () => HTMLButtonElement | null; + + findTrigger: () => Promise; + getTrigger: () => HTMLButtonElement; + queryTrigger: () => HTMLButtonElement | null; + + findMenu: () => Promise; + getMenu: () => HTMLElement; + queryMenu: () => HTMLElement | null; + + findMenuItems: () => Promise>; + getMenuItems: () => Array; + queryMenuItems: () => Array; + + isDisabled: FormUtils['isDisabled']; + openMenu: (options?: { withKeyboard?: boolean }) => Promise; + closeMenu: (options?: { withKeyboard?: boolean }) => Promise; +} diff --git a/packages/split-button/src/testing/index.ts b/packages/split-button/src/testing/index.ts new file mode 100644 index 0000000000..31794560e1 --- /dev/null +++ b/packages/split-button/src/testing/index.ts @@ -0,0 +1,2 @@ +export * from './getTestUtils'; +export * from './getTestUtils.types'; diff --git a/packages/split-button/tsconfig.json b/packages/split-button/tsconfig.json index d66eb66219..f6e983e0b4 100644 --- a/packages/split-button/tsconfig.json +++ b/packages/split-button/tsconfig.json @@ -50,6 +50,9 @@ }, { "path": "../leafygreen-provider" + }, + { + "path": "../tools/test-harnesses" } ] } From d4c963f18b6858d6c5917670ef653a95db234a82 Mon Sep 17 00:00:00 2001 From: Brooke Scarlett Yalof Date: Tue, 19 Aug 2025 12:26:40 -0400 Subject: [PATCH 2/9] Update packages/split-button/src/testing/getTestUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/split-button/src/testing/getTestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/split-button/src/testing/getTestUtils.ts b/packages/split-button/src/testing/getTestUtils.ts index ef7a7c2267..51421e9a4d 100644 --- a/packages/split-button/src/testing/getTestUtils.ts +++ b/packages/split-button/src/testing/getTestUtils.ts @@ -74,7 +74,7 @@ export const getTestUtils = ( if (options.withKeyboard) { trigger.focus(); - userEvent.keyboard('{enter}'); + await userEvent.keyboard('{enter}'); } else { userEvent.click(trigger); } From f6e0488502528a0c824677909cc499b55a3d0be0 Mon Sep 17 00:00:00 2001 From: Brooke Scarlett Yalof Date: Tue, 19 Aug 2025 12:26:56 -0400 Subject: [PATCH 3/9] Update packages/split-button/src/testing/getTestUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/split-button/src/testing/getTestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/split-button/src/testing/getTestUtils.ts b/packages/split-button/src/testing/getTestUtils.ts index 51421e9a4d..feaf0573d8 100644 --- a/packages/split-button/src/testing/getTestUtils.ts +++ b/packages/split-button/src/testing/getTestUtils.ts @@ -76,7 +76,7 @@ export const getTestUtils = ( trigger.focus(); await userEvent.keyboard('{enter}'); } else { - userEvent.click(trigger); + await userEvent.click(trigger); } // Wait for menu to appear From 35826f557f896c3128189b3a532b8b40dec4e2ce Mon Sep 17 00:00:00 2001 From: Brooke Scarlett Yalof Date: Tue, 19 Aug 2025 12:27:05 -0400 Subject: [PATCH 4/9] Update packages/split-button/src/testing/getTestUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/split-button/src/testing/getTestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/split-button/src/testing/getTestUtils.ts b/packages/split-button/src/testing/getTestUtils.ts index feaf0573d8..da395b9d77 100644 --- a/packages/split-button/src/testing/getTestUtils.ts +++ b/packages/split-button/src/testing/getTestUtils.ts @@ -94,7 +94,7 @@ export const getTestUtils = ( userEvent.keyboard('{escape}'); } else { // Click outside the menu to close it - userEvent.click(document.body); + await userEvent.click(document.body); } // Wait for menu to disappear - we'll use a try/catch since the menu might not exist From eb062c4d189fcdcedf6794b80270b5801c4e95bb Mon Sep 17 00:00:00 2001 From: Brooke Scarlett Yalof Date: Tue, 19 Aug 2025 12:27:13 -0400 Subject: [PATCH 5/9] Update packages/split-button/src/testing/getTestUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/split-button/src/testing/getTestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/split-button/src/testing/getTestUtils.ts b/packages/split-button/src/testing/getTestUtils.ts index da395b9d77..852f05d063 100644 --- a/packages/split-button/src/testing/getTestUtils.ts +++ b/packages/split-button/src/testing/getTestUtils.ts @@ -91,7 +91,7 @@ export const getTestUtils = ( */ const closeMenu = async (options: { withKeyboard?: boolean } = {}) => { if (options.withKeyboard) { - userEvent.keyboard('{escape}'); + await userEvent.keyboard('{escape}'); } else { // Click outside the menu to close it await userEvent.click(document.body); From 6a7e1130eb681648ce4259055970b161a2b32aa2 Mon Sep 17 00:00:00 2001 From: bruugey Date: Tue, 19 Aug 2025 12:31:50 -0400 Subject: [PATCH 6/9] re-run init and fix config --- packages/split-button/tsconfig.json | 2 +- pnpm-lock.yaml | 123 ++++++++++++++-------------- 2 files changed, 64 insertions(+), 61 deletions(-) diff --git a/packages/split-button/tsconfig.json b/packages/split-button/tsconfig.json index f6e983e0b4..700e44aaf2 100644 --- a/packages/split-button/tsconfig.json +++ b/packages/split-button/tsconfig.json @@ -52,7 +52,7 @@ "path": "../leafygreen-provider" }, { - "path": "../tools/test-harnesses" + "path": "../../tools/test-harnesses" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35fb8fad65..8a457c9c06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1972,7 +1972,7 @@ importers: version: 4.16.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806) + version: 10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819) xml2json: specifier: ^0.12.0 version: 0.12.0 @@ -3077,6 +3077,9 @@ importers: '@lg-tools/build': specifier: workspace:^ version: link:../../tools/build + '@lg-tools/test-harnesses': + specifier: workspace:^ + version: link:../../tools/test-harnesses packages/stepper: dependencies: @@ -4044,10 +4047,10 @@ importers: version: 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@storybook/react': specifier: 8.6.12 - version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806) + version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819) '@storybook/react-webpack5': specifier: 8.6.12 - version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806) + version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819) '@storybook/test': specifier: 8.6.12 version: 8.6.12(storybook@8.6.14(prettier@2.8.8)) @@ -4056,7 +4059,7 @@ importers: version: 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@svgr/webpack': specifier: 8.0.1 - version: 8.0.1(typescript@6.0.0-dev.20250806) + version: 8.0.1(typescript@6.0.0-dev.20250819) assert: specifier: ^2.1.0 version: 2.1.0 @@ -4098,7 +4101,7 @@ importers: version: 18.3.1 react-docgen-typescript: specifier: 2.2.2 - version: 2.2.2(typescript@6.0.0-dev.20250806) + version: 2.2.2(typescript@6.0.0-dev.20250819) react-dom: specifier: ^17.0.0 || ^18.0.0 version: 18.3.1(react@18.3.1) @@ -4249,7 +4252,7 @@ importers: version: 11.1.1 jest: specifier: 29.6.2 - version: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)) + version: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)) jest-axe: specifier: 8.0.0 version: 8.0.0 @@ -10881,8 +10884,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20250806: - resolution: {integrity: sha512-inDvi8ujsZXA/dSgj8QiSjHSi7fYDnkRck9vvnd400VBY5RSzNl6G3zZXjFZTawwYfETOakld5vQ6a36JuNOBQ==} + typescript@6.0.0-dev.20250819: + resolution: {integrity: sha512-GEasq8VlCMmxmydjEsX8RghmnsEg1mbxYfDGHjqQC6W58Se1bsqk8nD6uJ79GqkspHFB28UMN17+gnPtG/UPNQ==} engines: {node: '>=14.17'} hasBin: true @@ -13460,7 +13463,7 @@ snapshots: - ts-node optional: true - '@jest/core@29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806))': + '@jest/core@29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -13474,7 +13477,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)) + jest-config: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -13531,7 +13534,7 @@ snapshots: - ts-node optional: true - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -13545,7 +13548,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)) + jest-config: 29.7.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -14074,7 +14077,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-webpack5@8.6.12(esbuild@0.25.8)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806)': + '@storybook/builder-webpack5@8.6.12(esbuild@0.25.8)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819)': dependencies: '@storybook/core-webpack': 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@types/semver': 7.7.0 @@ -14084,7 +14087,7 @@ snapshots: constants-browserify: 1.0.0 css-loader: 6.11.0(webpack@5.88.0(esbuild@0.25.8)) es-module-lexer: 1.7.0 - fork-ts-checker-webpack-plugin: 8.0.0(typescript@6.0.0-dev.20250806)(webpack@5.88.0(esbuild@0.25.8)) + fork-ts-checker-webpack-plugin: 8.0.0(typescript@6.0.0-dev.20250819)(webpack@5.88.0(esbuild@0.25.8)) html-webpack-plugin: 5.6.3(webpack@5.88.0(esbuild@0.25.8)) magic-string: 0.30.17 path-browserify: 1.0.1 @@ -14102,7 +14105,7 @@ snapshots: webpack-hot-middleware: 2.26.1 webpack-virtual-modules: 0.6.2 optionalDependencies: - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 transitivePeerDependencies: - '@rspack/core' - '@swc/core' @@ -14186,11 +14189,11 @@ snapshots: dependencies: storybook: 8.6.14(prettier@2.8.8) - '@storybook/preset-react-webpack@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806)': + '@storybook/preset-react-webpack@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819)': dependencies: '@storybook/core-webpack': 8.6.12(storybook@8.6.14(prettier@2.8.8)) - '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806) - '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@6.0.0-dev.20250806)(webpack@5.88.0(esbuild@0.25.8)) + '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819) + '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@6.0.0-dev.20250819)(webpack@5.88.0(esbuild@0.25.8)) '@types/semver': 7.7.0 find-up: 5.0.0 magic-string: 0.30.17 @@ -14203,7 +14206,7 @@ snapshots: tsconfig-paths: 4.2.0 webpack: 5.88.0(esbuild@0.25.8) optionalDependencies: - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 transitivePeerDependencies: - '@storybook/test' - '@swc/core' @@ -14216,16 +14219,16 @@ snapshots: dependencies: storybook: 8.6.14(prettier@2.8.8) - '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@6.0.0-dev.20250806)(webpack@5.88.0(esbuild@0.25.8))': + '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@6.0.0-dev.20250819)(webpack@5.88.0(esbuild@0.25.8))': dependencies: debug: 4.4.1 endent: 2.1.0 find-cache-dir: 3.3.2 flat-cache: 3.2.0 micromatch: 4.0.8 - react-docgen-typescript: 2.2.2(typescript@6.0.0-dev.20250806) + react-docgen-typescript: 2.2.2(typescript@6.0.0-dev.20250819) tslib: 2.8.1 - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 webpack: 5.88.0(esbuild@0.25.8) transitivePeerDependencies: - supports-color @@ -14236,16 +14239,16 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.14(prettier@2.8.8) - '@storybook/react-webpack5@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806)': + '@storybook/react-webpack5@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819)': dependencies: - '@storybook/builder-webpack5': 8.6.12(esbuild@0.25.8)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806) - '@storybook/preset-react-webpack': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806) - '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806) + '@storybook/builder-webpack5': 8.6.12(esbuild@0.25.8)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819) + '@storybook/preset-react-webpack': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819) + '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) storybook: 8.6.14(prettier@2.8.8) optionalDependencies: - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 transitivePeerDependencies: - '@rspack/core' - '@storybook/test' @@ -14270,7 +14273,7 @@ snapshots: '@storybook/test': 8.6.12(storybook@8.6.14(prettier@2.8.8)) typescript: 5.8.3 - '@storybook/react@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250806)': + '@storybook/react@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250819)': dependencies: '@storybook/components': 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@storybook/global': 5.0.0 @@ -14283,7 +14286,7 @@ snapshots: storybook: 8.6.14(prettier@2.8.8) optionalDependencies: '@storybook/test': 8.6.12(storybook@8.6.14(prettier@2.8.8)) - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 '@storybook/test@8.5.3(storybook@8.6.14(prettier@2.8.8))': dependencies: @@ -14449,12 +14452,12 @@ snapshots: - supports-color - typescript - '@svgr/core@8.0.0(typescript@6.0.0-dev.20250806)': + '@svgr/core@8.0.0(typescript@6.0.0-dev.20250819)': dependencies: '@babel/core': 7.24.3 '@svgr/babel-preset': 8.0.0(@babel/core@7.24.3) camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@6.0.0-dev.20250806) + cosmiconfig: 8.3.6(typescript@6.0.0-dev.20250819) snake-case: 3.0.4 transitivePeerDependencies: - supports-color @@ -14499,11 +14502,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@svgr/plugin-jsx@8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250806))': + '@svgr/plugin-jsx@8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250819))': dependencies: '@babel/core': 7.24.3 '@svgr/babel-preset': 8.0.0(@babel/core@7.24.3) - '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250806) + '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250819) '@svgr/hast-util-to-babel-ast': 8.0.0 svg-parser: 2.0.4 transitivePeerDependencies: @@ -14534,10 +14537,10 @@ snapshots: transitivePeerDependencies: - typescript - '@svgr/plugin-svgo@8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250806))(typescript@6.0.0-dev.20250806)': + '@svgr/plugin-svgo@8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250819))(typescript@6.0.0-dev.20250819)': dependencies: - '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250806) - cosmiconfig: 8.3.6(typescript@6.0.0-dev.20250806) + '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250819) + cosmiconfig: 8.3.6(typescript@6.0.0-dev.20250819) deepmerge: 4.3.1 svgo: 3.3.2 transitivePeerDependencies: @@ -14568,16 +14571,16 @@ snapshots: - supports-color - typescript - '@svgr/webpack@8.0.1(typescript@6.0.0-dev.20250806)': + '@svgr/webpack@8.0.1(typescript@6.0.0-dev.20250819)': dependencies: '@babel/core': 7.24.3 '@babel/plugin-transform-react-constant-elements': 7.27.1(@babel/core@7.24.3) '@babel/preset-env': 7.24.3(@babel/core@7.24.3) '@babel/preset-react': 7.24.1(@babel/core@7.24.3) '@babel/preset-typescript': 7.24.1(@babel/core@7.24.3) - '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250806) - '@svgr/plugin-jsx': 8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250806)) - '@svgr/plugin-svgo': 8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250806))(typescript@6.0.0-dev.20250806) + '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250819) + '@svgr/plugin-jsx': 8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250819)) + '@svgr/plugin-svgo': 8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250819))(typescript@6.0.0-dev.20250819) transitivePeerDependencies: - supports-color - typescript @@ -15875,14 +15878,14 @@ snapshots: optionalDependencies: typescript: 5.8.3 - cosmiconfig@8.3.6(typescript@6.0.0-dev.20250806): + cosmiconfig@8.3.6(typescript@6.0.0-dev.20250819): dependencies: import-fresh: 3.3.1 js-yaml: 4.1.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 create-ecdh@4.0.4: dependencies: @@ -16262,7 +16265,7 @@ snapshots: dependencies: semver: 7.7.2 shelljs: 0.8.5 - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 dunder-proto@1.0.1: dependencies: @@ -16882,7 +16885,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@8.0.0(typescript@6.0.0-dev.20250806)(webpack@5.88.0(esbuild@0.25.8)): + fork-ts-checker-webpack-plugin@8.0.0(typescript@6.0.0-dev.20250819)(webpack@5.88.0(esbuild@0.25.8)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -16896,7 +16899,7 @@ snapshots: schema-utils: 3.3.0 semver: 7.7.2 tapable: 2.2.2 - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 webpack: 5.88.0(esbuild@0.25.8) form-data@2.5.5: @@ -17580,16 +17583,16 @@ snapshots: - ts-node optional: true - jest-cli@29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)): + jest-cli@29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 import-local: 3.2.0 - jest-config: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)) + jest-config: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)) jest-util: 29.7.0 jest-validate: 29.7.0 prompts: 2.4.2 @@ -17632,7 +17635,7 @@ snapshots: - supports-color optional: true - jest-config@29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)): + jest-config@29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)): dependencies: '@babel/core': 7.24.3 '@jest/test-sequencer': 29.7.0 @@ -17658,7 +17661,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.9 - ts-node: 10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806) + ts-node: 10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -17695,7 +17698,7 @@ snapshots: - supports-color optional: true - jest-config@29.7.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)): + jest-config@29.7.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)): dependencies: '@babel/core': 7.24.3 '@jest/test-sequencer': 29.7.0 @@ -17721,7 +17724,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.9 - ts-node: 10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806) + ts-node: 10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -17989,12 +17992,12 @@ snapshots: - ts-node optional: true - jest@29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)): + jest@29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)): dependencies: - '@jest/core': 29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)) + '@jest/core': 29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806)) + jest-cli: 29.6.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -19387,9 +19390,9 @@ snapshots: dependencies: typescript: 5.8.3 - react-docgen-typescript@2.2.2(typescript@6.0.0-dev.20250806): + react-docgen-typescript@2.2.2(typescript@6.0.0-dev.20250819): dependencies: - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 react-docgen@7.1.1: dependencies: @@ -20329,7 +20332,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250806): + ts-node@10.9.2(@types/node@20.19.9)(typescript@6.0.0-dev.20250819): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -20343,7 +20346,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 6.0.0-dev.20250806 + typescript: 6.0.0-dev.20250819 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 @@ -20455,7 +20458,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20250806: {} + typescript@6.0.0-dev.20250819: {} unbox-primitive@1.1.0: dependencies: From bbf2fd4e872e4a126ac28883eb811f2400703ad8 Mon Sep 17 00:00:00 2001 From: bruugey Date: Tue, 19 Aug 2025 12:32:20 -0400 Subject: [PATCH 7/9] with changesets --- .changeset/rare-guests-rescue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rare-guests-rescue.md diff --git a/.changeset/rare-guests-rescue.md b/.changeset/rare-guests-rescue.md new file mode 100644 index 0000000000..39f6641bb0 --- /dev/null +++ b/.changeset/rare-guests-rescue.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/split-button': minor +--- + +Adds and exports test harnesses from SplitButton package From 60378aa61360b4351cba08ce9fe882bdd3aaf9cc Mon Sep 17 00:00:00 2001 From: bruugey Date: Tue, 19 Aug 2025 15:13:08 -0400 Subject: [PATCH 8/9] update local package.json --- packages/split-button/package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/split-button/package.json b/packages/split-button/package.json index 951e61eaa6..dfbe5c2bdf 100644 --- a/packages/split-button/package.json +++ b/packages/split-button/package.json @@ -33,13 +33,14 @@ "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/polymorphic": "workspace:^", "@leafygreen-ui/popover": "workspace:^", - "@leafygreen-ui/tokens": "workspace:^" + "@leafygreen-ui/tokens": "workspace:^", + "@lg-tools/test-harnesses": "workspace:^" }, "peerDependencies": { "@leafygreen-ui/leafygreen-provider": "workspace:^" }, "devDependencies": { - "@lg-tools/build": "workspace:^", - "@lg-tools/test-harnesses": "workspace:^" + "@lg-tools/build": "workspace:^" + } } From f53b3c98b5df1310821264d1b3d6e82c4caf5a0f Mon Sep 17 00:00:00 2001 From: bruugey Date: Tue, 19 Aug 2025 15:25:35 -0400 Subject: [PATCH 9/9] updates lock file --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f15e638d3a..b99c409743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3079,13 +3079,13 @@ importers: '@leafygreen-ui/tokens': specifier: workspace:^ version: link:../tokens + '@lg-tools/test-harnesses': + specifier: workspace:^ + version: link:../../tools/test-harnesses devDependencies: '@lg-tools/build': specifier: workspace:^ version: link:../../tools/build - '@lg-tools/test-harnesses': - specifier: workspace:^ - version: link:../../tools/test-harnesses packages/stepper: dependencies: