From 1fd77ca765633a524a9a92518b0b216b1562b03c Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 10 Dec 2025 14:46:06 +0000 Subject: [PATCH 1/8] setup --- resources/js/tests/browser/setup.js | 15 +++++++++++++++ vite.config.js | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 resources/js/tests/browser/setup.js diff --git a/resources/js/tests/browser/setup.js b/resources/js/tests/browser/setup.js new file mode 100644 index 00000000000..83570e4d845 --- /dev/null +++ b/resources/js/tests/browser/setup.js @@ -0,0 +1,15 @@ +import '../setup.js'; + +import { config as browserConfig } from 'vitest-browser-vue'; + +browserConfig.global.mocks = { + __: (key) => key, +}; + +browserConfig.global.directives = { + tooltip: () => {}, +}; + +if (typeof window !== 'undefined') { + window.__ = (key) => key; +} diff --git a/vite.config.js b/vite.config.js index 1ec6219f3c4..801837cb18d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -66,7 +66,7 @@ export default defineConfig(({ mode, command }) => { extends: true, test: { name: 'browser', - setupFiles: 'resources/js/tests/setup.js', + setupFiles: ['resources/js/tests/browser/setup.js', 'vitest-browser-vue'], include: ['resources/js/tests/browser/**/*.test.js'], browser: { enabled: true, From e32e08b47613bff8112002ef72557f936394de3f Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 10 Dec 2025 14:46:38 +0000 Subject: [PATCH 2/8] exclude `__screenshots` directory from version control --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 800394286ca..8edacb41bab 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ resources/dist resources/dist-dev resources/dist-frontend resources/dist-package +resources/js/tests/browser/__screenshots__ packages/cms/src/ui.css composer.lock .env From 597f0671e34e5b919c9694b248a6a1534d42fa33 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 10 Dec 2025 14:47:00 +0000 Subject: [PATCH 3/8] replace brittle JS tests with browser tests --- resources/js/tests/browser/Combobox.test.js | 420 +++++++++++++++++ .../js/tests/components/ui/Combobox.test.js | 433 ------------------ 2 files changed, 420 insertions(+), 433 deletions(-) create mode 100644 resources/js/tests/browser/Combobox.test.js delete mode 100644 resources/js/tests/components/ui/Combobox.test.js diff --git a/resources/js/tests/browser/Combobox.test.js b/resources/js/tests/browser/Combobox.test.js new file mode 100644 index 00000000000..d73b7871a15 --- /dev/null +++ b/resources/js/tests/browser/Combobox.test.js @@ -0,0 +1,420 @@ +import { expect, test, describe } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-vue'; +import { Combobox } from '@/components/ui'; + +test('can select option', async () => { + let currentValue = null; + + const screen = render(Combobox, { + props: { + modelValue: currentValue, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + + // Options are in a portal, use page to find them + const option = page.getByText('Jason', { exact: true }); + await option.click(); + + expect(currentValue).toEqual('jason'); + await expect.element(screen.container).toHaveTextContent('Jason'); +}); + +test('dropdown closes on selection', async () => { + let currentValue = []; + + const screen = render(Combobox, { + props: { + multiple: true, + closeOnSelect: true, + modelValue: currentValue, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + + const option = page.getByText('Jason', { exact: true }); + await option.click(); + + // Wait for the dropdown to close + await new Promise(resolve => setTimeout(resolve, 50)); + + // Dropdown should be closed - the dropdown element should not be visible + const dropdownQuery = await page.getByRole('listbox').query(); + expect(dropdownQuery).toBeNull(); + + // Selected option should be shown in the container + await expect.element(screen.container).toHaveTextContent('Jason'); +}); + +test('can clear selected option', async () => { + let currentValue = 'juncan'; + + const screen = render(Combobox, { + props: { + clearable: true, + modelValue: currentValue, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + { label: 'Joshua', value: 'joshua' }, + { label: 'Juncan', value: 'juncan' }, + { label: 'Jay', value: 'jay' }, + ], + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + await expect.element(screen.container).toHaveTextContent('Juncan'); + + const clearButton = screen.container.querySelector('[data-ui-combobox-clear-button]'); + await clearButton.click(); + + expect(currentValue).toBeNull(); + await expect.element(screen.container).not.toHaveTextContent('Juncan'); +}); + +test('can use different optionLabel and optionValue keys', async () => { + let currentValue = null; + + const screen = render(Combobox, { + props: { + modelValue: currentValue, + optionLabel: 'title', + optionValue: 'id', + options: [ + { title: 'Jack', id: 'jack' }, + { title: 'Jason', id: 'jason' }, + { title: 'Jesse', id: 'jesse' }, + ], + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + + const option = page.getByText('Jason', { exact: true }); + await option.click(); + + expect(currentValue).toEqual('jason'); + await expect.element(screen.container).toHaveTextContent('Jason'); +}); + +describe('multiple options', () => { + test('can select multiple options', async () => { + let currentValue = []; + + const screen = render(Combobox, { + props: { + multiple: true, + modelValue: currentValue, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + + // Select first option + await page.getByText('Jason', { exact: true }).click(); + + // Select second option + await page.getByText('Jesse', { exact: true }).click(); + + // Select third option + await page.getByText('Jack', { exact: true }).click(); + + // Model value should have been updated + expect(currentValue).toEqual(['jason', 'jesse', 'jack']); + + // All three should be visible in the container + await expect.element(screen.container).toHaveTextContent('Jason'); + await expect.element(screen.container).toHaveTextContent('Jesse'); + await expect.element(screen.container).toHaveTextContent('Jack'); + }); + + test('cant select more than the allowed number of options', async () => { + let currentValue = []; + + const screen = render(Combobox, { + props: { + multiple: true, + maxSelections: 2, + modelValue: currentValue, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + + // Select first option + await page.getByText('Jason', { exact: true }).click(); + + // Select second option + await page.getByText('Jesse', { exact: true }).click(); + + // Verify we have exactly 2 selections + expect(currentValue).toEqual(['jason', 'jesse']); + + // Only Jason and Jesse should be selected + await expect.element(screen.container).toHaveTextContent('Jason'); + await expect.element(screen.container).toHaveTextContent('Jesse'); + expect(screen.container.textContent).not.toContain('Jack'); + }); + + test('can deselect options', async () => { + let currentValue = ['jason', 'jesse', 'jack']; + + const screen = render(Combobox, { + props: { + multiple: true, + modelValue: currentValue, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + // Verify all three are initially selected + await expect.element(screen.container).toHaveTextContent('Jason'); + await expect.element(screen.container).toHaveTextContent('Jesse'); + await expect.element(screen.container).toHaveTextContent('Jack'); + + // Find all deselect buttons by aria-label + // The order in the UI is Jason, Jesse, Jack (same as modelValue array) + const removeButtons = screen.container.querySelectorAll('button[aria-label="Deselect option"]'); + + // Click the button for Jesse (index 1) + await removeButtons[1].click(); + + // Jesse should be removed + expect(currentValue).toEqual(['jason', 'jack']); + }); +}); + +describe('search', () => { + test('can search options', async () => { + const screen = render(Combobox, { + props: { + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + await userEvent.fill(combobox, 'jac'); + + // After filtering, only Jack should be visible + const jackOption = page.getByText('Jack', { exact: true }); + await expect.element(jackOption).toBeVisible(); + + // Jason should not be visible + const jasonQuery = await page.getByText('Jason', { exact: true }).query(); + expect(jasonQuery).toBeNull(); + }); + + test('cant search options when searchable prop is false', async () => { + const screen = render(Combobox, { + props: { + searchable: false, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + }, + }); + + const trigger = screen.container.querySelector('[data-ui-combobox-trigger]'); + await trigger.click(); + + // When searchable is false, there should be no searchbox role + const searchboxQuery = await screen.getByRole('combobox').query(); + expect(searchboxQuery).toBeNull(); + }); + + test("doesn't search when ignoreFilter prop is true", async () => { + const screen = render(Combobox, { + props: { + ignoreFilter: true, + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + { label: 'Jesse', value: 'jesse' }, + ], + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + await userEvent.fill(combobox, 'jac'); + + // All options should still be visible since filtering is ignored + await expect.element(page.getByText('Jack', { exact: true })).toBeVisible(); + await expect.element(page.getByText('Jason', { exact: true })).toBeVisible(); + await expect.element(page.getByText('Jesse', { exact: true })).toBeVisible(); + }); +}); + +describe('taggable', () => { + test('can append options', async () => { + let currentValue = []; + + const screen = render(Combobox, { + props: { + multiple: true, + taggable: true, + modelValue: currentValue, + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + await userEvent.fill(combobox, 'NewTag'); + await userEvent.keyboard('{Enter}'); + + expect(currentValue).toEqual(['NewTag']); + await expect.element(screen.container).toHaveTextContent('NewTag'); + }); + + test('can paste into search input', async () => { + let currentValue = []; + + const screen = render(Combobox, { + props: { + multiple: true, + taggable: true, + modelValue: currentValue, + 'onUpdate:modelValue': (newValue) => { + currentValue = newValue; + screen.rerender({ modelValue: newValue }); + }, + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + + // Simulate paste event + const input = combobox.element(); + const pasteEvent = new ClipboardEvent('paste', { + clipboardData: new DataTransfer(), + bubbles: true, + cancelable: true, + }); + pasteEvent.clipboardData.setData('text/plain', 'Tag1,Tag2,Tag3'); + input.dispatchEvent(pasteEvent); + + expect(currentValue).toEqual(['Tag1', 'Tag2', 'Tag3']); + await expect.element(screen.container).toHaveTextContent('Tag1'); + await expect.element(screen.container).toHaveTextContent('Tag2'); + await expect.element(screen.container).toHaveTextContent('Tag3'); + }); +}); + +describe('accessibility', () => { + test('dropdown opens on space', async () => { + const screen = render(Combobox, { + props: { + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + ], + }, + }); + + const combobox = screen.getByRole('combobox'); + // Focus the element directly + combobox.element().focus(); + await userEvent.keyboard(' '); + + // Check that options are visible + const jackOption = page.getByText('Jack', { exact: true }); + await expect.element(jackOption).toBeVisible(); + }); + + test('dropdown closes on escape', async () => { + const screen = render(Combobox, { + props: { + options: [ + { label: 'Jack', value: 'jack' }, + { label: 'Jason', value: 'jason' }, + ], + }, + }); + + const combobox = screen.getByRole('combobox'); + await combobox.click(); + + // Options should be visible + const jackOption = page.getByText('Jack', { exact: true }); + await expect.element(jackOption).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + + // Options should be hidden + const jackQuery = await page.getByText('Jack', { exact: true }).query(); + expect(jackQuery).toBeNull(); + }); +}); diff --git a/resources/js/tests/components/ui/Combobox.test.js b/resources/js/tests/components/ui/Combobox.test.js deleted file mode 100644 index f11287183f1..00000000000 --- a/resources/js/tests/components/ui/Combobox.test.js +++ /dev/null @@ -1,433 +0,0 @@ -import { expect, test, beforeEach, vi, describe } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { Combobox } from '@/components/ui'; - -// Mock the ComboboxVirtualizer to render options without virtualization. -// This is necessary because virtualization relies on browser APIs which aren't available in our tests. -vi.mock('reka-ui', async () => { - const actual = await vi.importActual('reka-ui'); - return { - ...actual, - ComboboxVirtualizer: { - name: 'ComboboxVirtualizer', - props: ['options', 'overscan', 'estimateSize', 'textContent'], - setup(props, { slots }) { - // Instead of virtualization, render all items directly - return () => { - const items = props.options.map((option, index) => { - return slots.default({ - option, - virtualItem: { index, key: index, start: index * 37 }, - virtualizer: { scrollToIndex: () => {} } - }); - }); - return items; - }; - } - } - }; -}); - -// Mock the Scrollbar component since it relies on DOM APIs not available in tests -vi.mock('@ui/Combobox/Scrollbar.vue', () => ({ - default: { - name: 'Scrollbar', - props: ['viewport'], - setup() { - return { - update: vi.fn() - }; - }, - template: '
' - } -})); - -beforeEach(() => { - Element.prototype.scrollIntoView = vi.fn(); - - global.__ = (key) => key; - - global.CSS = { - escape: (str) => str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&') - }; - - // Mock Canvas API for text measurement (needed for Combobox width calculation) - HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ - font: '', - measureText: vi.fn((text) => ({ - width: text.length * 8 // Rough estimate: 8px per character - })) - })); - - document.body.innerHTML = ''; -}); - -test('can select option', async () => { - const wrapper = mount(Combobox, { - props: { - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - // The option dropdown is rendered in a portal, so we need to find it in the document instead. - await document.querySelector('[data-ui-combobox-item="jason"]').click(); - - expect(wrapper.emitted('update:modelValue')[0]).toEqual(['jason']); - await wrapper.setProps({ modelValue: 'jason' }); - - expect(trigger.find('button').text()).toBe('Jason'); -}); - -test('dropdown closes on selection', async () => { - const wrapper = mount(Combobox, { - props: { - multiple: true, - closeOnSelect: true, - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - await wrapper.find('[data-ui-combobox-trigger]').trigger('click'); - - await document.querySelector('[data-ui-combobox-item="jason"]').click(); - expect(wrapper.emitted('update:modelValue')[0]).toEqual([['jason']]); - await wrapper.setProps({ modelValue: ['jason'] }); - - expect(wrapper.vm.dropdownOpen).toBeFalsy(); - - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Jason'); -}); - -test('can clear selected option', async () => { - const wrapper = mount(Combobox, { - props: { - clearable: true, - modelValue: 'juncan', - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - await wrapper.find('[data-ui-combobox-clear-button]').trigger('click'); - - expect(wrapper.vm.searchQuery).toBe(''); - expect(wrapper.emitted('update:modelValue')[0]).toEqual([null]); -}); - -test('can use different optionLabel and optionValue keys', async () => { - const wrapper = mount(Combobox, { - props: { - optionLabel: 'title', - optionValue: 'id', - options: [ - { title: 'Jack', id: 'jack' }, - { title: 'Jason', id: 'jason' }, - { title: 'Jesse', id: 'jesse' }, - { title: 'Joshua', id: 'joshua' }, - { title: 'Juncan', id: 'juncan' }, - { title: 'Jay', id: 'jay' }, - ], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - // The option dropdown is rendered in a portal, so we need to find it in the document instead. - await document.querySelector('[data-ui-combobox-item="jason"]').click(); - - expect(wrapper.emitted('update:modelValue')[0]).toEqual(['jason']); - await wrapper.setProps({ modelValue: 'jason' }); - - expect(trigger.find('button').text()).toBe('Jason'); -}); - -describe('multiple options', () => { - test('can select multiple options', async () => { - const wrapper = mount(Combobox, { - props: { - multiple: true, - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - await wrapper.find('[data-ui-combobox-trigger]').trigger('click'); - - await document.querySelector('[data-ui-combobox-item="jason"]').click(); - expect(wrapper.emitted('update:modelValue')[0]).toEqual([['jason']]); - await wrapper.setProps({ modelValue: ['jason'] }); - - await document.querySelector('[data-ui-combobox-item="jesse"]').click(); - expect(wrapper.emitted('update:modelValue')[1]).toEqual([['jason', 'jesse']]); - await wrapper.setProps({ modelValue: ['jason', 'jesse'] }); - - await document.querySelector('[data-ui-combobox-item="juncan"]').click(); - expect(wrapper.emitted('update:modelValue')[2]).toEqual([['jason', 'jesse', 'juncan']]); - await wrapper.setProps({ modelValue: ['jason', 'jesse', 'juncan'] }); - - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Jason'); - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Jesse'); - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Juncan'); - }); - - test('cant select more than the allowed number of options', async () => { - const wrapper = mount(Combobox, { - props: { - multiple: true, - maxSelections: 2, - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - await wrapper.find('[data-ui-combobox-trigger]').trigger('click'); - - await document.querySelector('[data-ui-combobox-item="jason"]').click(); - expect(wrapper.emitted('update:modelValue')[0]).toEqual([['jason']]); - await wrapper.setProps({ modelValue: ['jason'] }); - - await document.querySelector('[data-ui-combobox-item="jesse"]').click(); - expect(wrapper.emitted('update:modelValue')[1]).toEqual([['jason', 'jesse']]); - await wrapper.setProps({ modelValue: ['jason', 'jesse'] }); - - await document.querySelector('[data-ui-combobox-item="juncan"]').click(); - expect(wrapper.emitted('update:modelValue')).toHaveLength(2); // No new event should be emitted - - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Jason'); - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Jesse'); - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).not.toContain('Juncan'); - }); - - test('can deselect options', async () => { - const wrapper = mount(Combobox, { - props: { - multiple: true, - modelValue: ['jason', 'jesse', 'juncan'], - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - wrapper.find('[data-ui-combobox-selected-options] :nth-child(2) button').trigger('click'); - - expect(wrapper.emitted('update:modelValue')[0]).toEqual([['jason', 'juncan']]); - await wrapper.setProps({ modelValue: ['jason', 'juncan'] }); - - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Jason'); - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).not.toContain('Jesse'); - expect(wrapper.find('[data-ui-combobox-selected-options]').text()).toContain('Juncan'); - }); -}); - -describe('search', () => { - test('can search options', async () => { - const wrapper = mount(Combobox, { - props: { - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - await trigger.find('input[type="search"]').setValue('jac'); - - expect(wrapper.vm.filteredOptions).toEqual([ - { label: 'Jack', value: 'jack' }, - ]); - }); - - test('cant search options when searchable prop is false', async () => { - const wrapper = mount(Combobox, { - props: { - searchable: false, - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - expect(trigger.find('input[type="search"]').exists()).toBeFalsy(); - }); - - test("doesn't search when ignoreFilter prop is true", async () => { - const wrapper = mount(Combobox, { - props: { - ignoreFilter: true, - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - await trigger.find('input[type="search"]').setValue('jac'); - - expect(wrapper.emitted('search')[0][0]).toEqual('jac'); - }); -}); - -describe('taggable', () => { - test('can append options', async () => { - const wrapper = mount(Combobox, { - props: { - multiple: true, - taggable: true, - modelValue: [], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - const searchInput = trigger.find('input[type="search"]'); - await searchInput.setValue('Jack'); - await searchInput.trigger('keydown.enter'); - - expect(wrapper.emitted('update:modelValue')[0]).toEqual([['Jack']]); - }); - - test('can paste into search input', async () => { - const wrapper = mount(Combobox, { - props: { - multiple: true, - taggable: true, - modelValue: [], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - const searchInput = trigger.find('input[type="search"]'); - - await searchInput.trigger('paste', { - clipboardData: { - getData: () => 'Jack,Jason,Jesse', - types: ['text/plain'] - } - }); - - expect(wrapper.emitted('update:modelValue')[0]).toEqual([['Jack', 'Jason', 'Jesse']]); - }); -}); - -describe('accessibility', () => { - test('dropdown opens on space', async () => { - const wrapper = mount(Combobox, { - props: { - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - expect(wrapper.vm.dropdownOpen).toBeFalsy(); - - await wrapper.find('[data-ui-combobox-trigger]').trigger('keydown.space'); - - expect(wrapper.vm.dropdownOpen).toBeTruthy(); - }); - - test('dropdown closes on escape', async () => { - const wrapper = mount(Combobox, { - props: { - closeOnSelect: false, - options: [ - { label: 'Jack', value: 'jack' }, - { label: 'Jason', value: 'jason' }, - { label: 'Jesse', value: 'jesse' }, - { label: 'Joshua', value: 'joshua' }, - { label: 'Juncan', value: 'juncan' }, - { label: 'Jay', value: 'jay' }, - ], - }, - }); - - const trigger = wrapper.find('[data-ui-combobox-trigger]'); - await trigger.trigger('click'); - - expect(wrapper.vm.dropdownOpen).toBeTruthy(); - - const options = document.querySelector('[data-ui-combobox-content]'); - - options.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Escape', - keyCode: 27, - which: 27, - bubbles: true, - cancelable: true - })); - - expect(wrapper.vm.dropdownOpen).toBeFalsy(); - }); -}); From 4e4559cd39ecbd5d4ec437697e07a955ebdac697 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 10 Dec 2025 14:47:21 +0000 Subject: [PATCH 4/8] wip --- resources/js/components/ui/Combobox/Combobox.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/ui/Combobox/Combobox.vue b/resources/js/components/ui/Combobox/Combobox.vue index 1cebaea6264..687e98f300e 100644 --- a/resources/js/components/ui/Combobox/Combobox.vue +++ b/resources/js/components/ui/Combobox/Combobox.vue @@ -423,7 +423,7 @@ defineExpose({ @mount-auto-focus.prevent @unmount-auto-focus="(event) => { if (event.defaultPrevented) return; - $refs.trigger.$el.focus(); + $refs.trigger?.$el?.focus(); event.preventDefault(); }" > From fd266a022b3299d82c8f9bb86f279e102868c75e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 10 Dec 2025 15:05:07 +0000 Subject: [PATCH 5/8] wire up css so the screenshots aren't so lame --- resources/js/tests/browser/setup.js | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/resources/js/tests/browser/setup.js b/resources/js/tests/browser/setup.js index 83570e4d845..0b9a06a102d 100644 --- a/resources/js/tests/browser/setup.js +++ b/resources/js/tests/browser/setup.js @@ -2,6 +2,9 @@ import '../setup.js'; import { config as browserConfig } from 'vitest-browser-vue'; +import '../../../css/app.css'; +import '../../../css/ui.css'; + browserConfig.global.mocks = { __: (key) => key, }; @@ -13,3 +16,51 @@ browserConfig.global.directives = { if (typeof window !== 'undefined') { window.__ = (key) => key; } + +if (typeof document !== 'undefined') { + const style = document.createElement('style'); + style.textContent = ` + :root { + /* Basic theme colors */ + --theme-color-primary: #3b82f6; + --theme-color-success: #10b981; + --theme-color-gray-50: #f9fafb; + --theme-color-gray-100: #f3f4f6; + --theme-color-gray-150: #e8ebef; + --theme-color-gray-200: #e5e7eb; + --theme-color-gray-300: #d1d5db; + --theme-color-gray-400: #9ca3af; + --theme-color-gray-500: #6b7280; + --theme-color-gray-600: #4b5563; + --theme-color-gray-700: #374151; + --theme-color-gray-800: #1f2937; + --theme-color-gray-850: #1a202c; + --theme-color-gray-900: #111827; + --theme-color-gray-925: #0d1117; + + /* UI specific colors */ + --theme-color-body-bg: #ffffff; + --theme-color-body-border: #e5e7eb; + --theme-color-content-border: #e5e7eb; + --theme-color-focus-outline: #3b82f6; + + /* Z-index values */ + --z-index-above: 10; + --z-index-portal: 1000; + --z-index-modal: 1050; + } + + /* Ensure body has a white background */ + body { + background-color: white; + color: #111827; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + /* Basic button/input styling for visibility */ + button, input, select { + font-family: inherit; + } + `; + document.head.appendChild(style); +} \ No newline at end of file From ba7e7830a1b22d98432619cf7de42aa71970609f Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 10 Dec 2025 15:05:42 +0000 Subject: [PATCH 6/8] add a few simple button tests --- resources/js/tests/browser/Button.test.js | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 resources/js/tests/browser/Button.test.js diff --git a/resources/js/tests/browser/Button.test.js b/resources/js/tests/browser/Button.test.js new file mode 100644 index 00000000000..d8d811ba37a --- /dev/null +++ b/resources/js/tests/browser/Button.test.js @@ -0,0 +1,52 @@ +import { expect, test } from 'vitest'; +import { render } from 'vitest-browser-vue'; +import { Button } from '@/components/ui'; + +test('renders button with text prop', async () => { + const screen = render(Button, { + props: { + text: 'Click Me', + }, + }); + + expect(screen.container.textContent).toContain('Click Me'); +}); + +test('renders button with slot', async () => { + const screen = render(Button, { + slots: { + default: `Hello, World!`, + }, + }); + + expect(screen.container.textContent).toContain('Hello, World!'); +}); + +test('can click button', async () => { + let clicked = false; + + const screen = render(Button, { + props: { + text: 'Click Me', + onClick: () => { clicked = true; }, + }, + }); + + const button = screen.getByRole('button'); + await button.click(); + + expect(clicked).toBe(true); +}); + +test('disabled button', async () => { + const screen = render(Button, { + props: { + text: 'Click Me', + disabled: true, + }, + }); + + const button = screen.getByRole('button'); + + await expect.element(button).toBeDisabled(); +}); From 30956ee82cdb250d76bf565779a502c340715b5f Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 10 Dec 2025 15:06:40 +0000 Subject: [PATCH 7/8] move into `ui` subdirectory --- resources/js/tests/browser/{ => ui}/Button.test.js | 2 +- resources/js/tests/browser/{ => ui}/Combobox.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename resources/js/tests/browser/{ => ui}/Button.test.js (96%) rename resources/js/tests/browser/{ => ui}/Combobox.test.js (99%) diff --git a/resources/js/tests/browser/Button.test.js b/resources/js/tests/browser/ui/Button.test.js similarity index 96% rename from resources/js/tests/browser/Button.test.js rename to resources/js/tests/browser/ui/Button.test.js index d8d811ba37a..c6c360bacc8 100644 --- a/resources/js/tests/browser/Button.test.js +++ b/resources/js/tests/browser/ui/Button.test.js @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; import { render } from 'vitest-browser-vue'; -import { Button } from '@/components/ui'; +import { Button } from '@ui'; test('renders button with text prop', async () => { const screen = render(Button, { diff --git a/resources/js/tests/browser/Combobox.test.js b/resources/js/tests/browser/ui/Combobox.test.js similarity index 99% rename from resources/js/tests/browser/Combobox.test.js rename to resources/js/tests/browser/ui/Combobox.test.js index d73b7871a15..0e7b9963135 100644 --- a/resources/js/tests/browser/Combobox.test.js +++ b/resources/js/tests/browser/ui/Combobox.test.js @@ -1,7 +1,7 @@ import { expect, test, describe } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import { render } from 'vitest-browser-vue'; -import { Combobox } from '@/components/ui'; +import { Combobox } from '@ui'; test('can select option', async () => { let currentValue = null; From 6f1f8c42a133142a06797d32d2432c45569833be Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 18 Dec 2025 11:35:12 -0500 Subject: [PATCH 8/8] set up storybook vitest tests --- .storybook/main.ts | 3 +- package-lock.json | 202 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + vite.config.js | 19 +++++ 4 files changed, 221 insertions(+), 4 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index 22b876dde32..948f5b2b606 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -7,7 +7,8 @@ const config: StorybookConfig = { ], addons: [ '@storybook/addon-docs', - '@storybook/addon-a11y' + '@storybook/addon-a11y', + '@storybook/addon-vitest' ], staticDirs: ['./public'], framework: { diff --git a/package-lock.json b/package-lock.json index b224439b6ee..36da8ea43cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "devDependencies": { "@storybook/addon-a11y": "^10.1.2", "@storybook/addon-docs": "^10.1.2", + "@storybook/addon-vitest": "^10.1.10", "@storybook/vue3-vite": "^10.1.2", "@tailwindcss/vite": "^4.1.8", "@vitejs/plugin-vue": "^6.0.0", @@ -1715,6 +1716,42 @@ "storybook": "^10.1.2" } }, + "node_modules/@storybook/addon-vitest": { + "version": "10.1.10", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.1.10.tgz", + "integrity": "sha512-dh5ZesgvZY619nkweo9fbORQQSU0hIFQnqlcnU1DrGXumt9SzVHF3/2Lxe+HGHLHK6Sk8jZp/16BjZ/zxSG61Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@vitest/browser": "^3.0.0 || ^4.0.0", + "@vitest/browser-playwright": "^4.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0", + "storybook": "^10.1.10", + "vitest": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/runner": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@storybook/builder-vite": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.2.tgz", @@ -3736,6 +3773,22 @@ "node": ">=8" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4206,6 +4259,36 @@ "node": ">=6" } }, + "node_modules/default-browser": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", + "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -5200,6 +5283,41 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7197,6 +7315,19 @@ "dev": true, "license": "MIT" }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7443,9 +7574,9 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.2.tgz", - "integrity": "sha512-yFL15WVQJeagmptyRadd2cwJlMVCo6xPoTPt/R+lQXIJmsTDHOFl5cZooIsvgALe3hTi5hsuVL3pG2bPEUuYGg==", + "version": "10.1.10", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.10.tgz", + "integrity": "sha512-oK0t0jEogiKKfv5Z1ao4Of99+xWw1TMUGuGRYDQS4kp2yyBsJQEgu7NI7OLYsCDI6gzt5p3RPtl1lqdeVLUi8A==", "dev": true, "license": "MIT", "dependencies": { @@ -7456,6 +7587,7 @@ "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.6.2", "use-sync-external-store": "^1.5.0", @@ -7552,6 +7684,38 @@ "node": ">=18" } }, + "node_modules/storybook/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/storybook/node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/storybook/node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", @@ -8830,6 +8994,38 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 50498321692..4b7deea68a2 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "devDependencies": { "@storybook/addon-a11y": "^10.1.2", "@storybook/addon-docs": "^10.1.2", + "@storybook/addon-vitest": "^10.1.10", "@storybook/vue3-vite": "^10.1.2", "@tailwindcss/vite": "^4.1.8", "@vitejs/plugin-vue": "^6.0.0", diff --git a/vite.config.js b/vite.config.js index 801837cb18d..f5c5a2ea421 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,6 +7,7 @@ import svgLoader from 'vite-svg-loader'; import path from 'path'; import { playwright } from '@vitest/browser-playwright'; import tsconfigPaths from 'vite-tsconfig-paths'; +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; export default defineConfig(({ mode, command }) => { const env = loadEnv(mode, process.cwd(), ''); @@ -75,6 +76,24 @@ export default defineConfig(({ mode, command }) => { }, }, }, + { + extends: true, + plugins: [ + storybookTest({ + configDir: '.storybook', + }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + setupFiles: ['.storybook/vitest.setup.ts'], + }, + }, ], }, define: {