diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml
index 385572875..20b104eb3 100644
--- a/.github/workflows/lintBuildTest.yml
+++ b/.github/workflows/lintBuildTest.yml
@@ -51,18 +51,24 @@ jobs:
- name: Check formatting
run: npm run fmt:check
- name: Test
- run: npm test run
+ run: npm test run -- --project unit
- name: Build
run: npm run build
playwright:
- name: Playwright (${{ matrix.browser }})
+ name: Playwright (${{ matrix.playwright }})
timeout-minutes: 20
runs-on: macos-15-xlarge
needs: install
strategy:
fail-fast: false
matrix:
- browser: ['chrome', 'firefox', 'safari']
+ include:
+ - playwright: chrome
+ vitest: chromium
+ - playwright: firefox
+ vitest: firefox
+ - playwright: safari
+ vitest: webkit
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
@@ -95,11 +101,13 @@ jobs:
- name: 🏗 Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps
+ - name: Run Vitest browser tests
+ run: npm run test:browser -- run --browser.name=${{ matrix.vitest }}
- name: Run Playwright browser tests
- run: npx playwright test --project=${{matrix.browser}}
+ run: npx playwright test --project=${{ matrix.playwright }}
- uses: actions/upload-artifact@v7
if: always()
with:
- name: test-results-${{ matrix.browser }}
+ name: test-results-${{ matrix.playwright }}
path: test-results/
retention-days: 7
diff --git a/app/components/DocsPopover.browser.spec.tsx b/app/components/DocsPopover.browser.spec.tsx
new file mode 100644
index 000000000..eade79a20
--- /dev/null
+++ b/app/components/DocsPopover.browser.spec.tsx
@@ -0,0 +1,66 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { expect, test } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+
+import { Document16Icon } from '@oxide/design-system/icons/react'
+
+import { DocsPopover } from './DocsPopover'
+
+function DocsPopoverHarness() {
+ return (
+ <>
+
+ }
+ summary="Create, attach, and manage disks."
+ links={[{ href: 'https://example.com/disks', linkText: 'Disks and Snapshots' }]}
+ />
+ >
+ )
+}
+
+test('opens contextual help with an external guide link', async () => {
+ const screen = await render()
+
+ await screen.getByTitle('Learn about disks').click()
+
+ await expect
+ .element(screen.getByRole('heading', { name: 'Learn about disks' }))
+ .toBeVisible()
+ await expect.element(screen.getByText('Create, attach, and manage disks.')).toBeVisible()
+ await expect
+ .element(screen.getByRole('link', { name: 'Disks and Snapshots' }))
+ .toHaveAttribute('target', '_blank')
+})
+
+test('closes with Escape and restores focus to the trigger', async () => {
+ const screen = await render()
+ const trigger = screen.getByTitle('Learn about disks')
+
+ await trigger.click()
+ await userEvent.keyboard('{Escape}')
+
+ await expect
+ .element(screen.getByRole('heading', { name: 'Learn about disks' }))
+ .not.toBeInTheDocument()
+ await expect.element(trigger).toHaveFocus()
+})
+
+test('closes when an unobscured outside control is clicked', async () => {
+ const screen = await render()
+
+ await screen.getByTitle('Learn about disks').click()
+ await screen.getByRole('button', { name: 'Outside' }).click()
+
+ await expect
+ .element(screen.getByRole('heading', { name: 'Learn about disks' }))
+ .not.toBeInTheDocument()
+})
diff --git a/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx b/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx
new file mode 100644
index 000000000..2c07be760
--- /dev/null
+++ b/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx
@@ -0,0 +1,118 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import {
+ getLocalTimeZone,
+ now,
+ parseDateTime,
+ type DateValue,
+} from '@internationalized/date'
+import { useState } from 'react'
+import { expect, test } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+
+import { DateTimeRangePicker, type RangeKeyAll } from './DateTimeRangePicker'
+
+const end = parseDateTime('2024-01-02T12:00')
+const range = { start: end.subtract({ days: 1 }), end }
+const current = now(getLocalTimeZone())
+
+function DateTimeRangePickerHarness({
+ initialRange = range,
+}: {
+ initialRange?: { start: DateValue; end: DateValue }
+}) {
+ const [preset, setPreset] = useState('lastDay')
+ const [selectedRange, setRange] = useState(initialRange)
+ return (
+ <>
+
+
+ >
+ )
+}
+
+test('chooses a preset with the pointer', async () => {
+ const screen = await render()
+
+ await screen.getByRole('button', { name: 'Choose a time range preset' }).click()
+ await screen.getByRole('option', { name: 'Last week' }).click()
+
+ await expect.element(screen.getByText('Preset: lastWeek')).toBeVisible()
+ await expect
+ .element(screen.getByRole('button', { name: 'Choose a time range preset' }))
+ .toHaveTextContent('Last week')
+})
+
+test('chooses a preset with the keyboard', async () => {
+ const screen = await render()
+ const preset = screen.getByRole('button', { name: 'Choose a time range preset' })
+
+ preset.element().focus()
+ await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}')
+
+ await expect.element(screen.getByText('Preset: lastWeek')).toBeVisible()
+ await expect.element(preset).toHaveFocus()
+})
+
+test('closes the date-range dialog with Escape and restores focus', async () => {
+ const screen = await render()
+ const dateRange = screen.getByLabelText('Choose a date range').getByRole('button')
+
+ await dateRange.click()
+ await expect.element(screen.getByRole('dialog')).toBeVisible()
+ await userEvent.keyboard('{Escape}')
+
+ await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
+ await expect.element(dateRange).toHaveFocus()
+})
+
+test('chooses a custom date range with the calendar keyboard controls', async () => {
+ const screen = await render(
+
+ )
+
+ await screen.getByLabelText('Choose a date range').getByRole('button').click()
+ await screen.getByRole('button', { name: /Today/ }).click()
+ await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}{Enter}{Escape}')
+
+ await expect.element(screen.getByText('Preset: custom')).toBeVisible()
+})
+
+test('shows an error when the start time is after the end time', async () => {
+ const screen = await render(
+
+ )
+
+ await screen.getByLabelText('Choose a date range').getByRole('button').click()
+ const today = screen.getByRole('button', { name: /Today/ })
+ await today.click()
+ await today.click()
+
+ const hours = screen.getByRole('spinbutton', { name: 'hour,' })
+ const minutes = screen.getByRole('spinbutton', { name: 'minute,' })
+ await hours.nth(0).click()
+ await userEvent.keyboard('23')
+ await minutes.nth(0).click()
+ await userEvent.keyboard('00')
+ await hours.nth(1).click()
+ await userEvent.keyboard('01')
+ await minutes.nth(1).click()
+ await userEvent.keyboard('00')
+
+ await expect.element(screen.getByText('Date range is invalid')).toBeVisible()
+})
diff --git a/app/components/form/fields/DateTimeRangePicker.tsx b/app/components/form/fields/DateTimeRangePicker.tsx
index 15dcd64b4..81d62af44 100644
--- a/app/components/form/fields/DateTimeRangePicker.tsx
+++ b/app/components/form/fields/DateTimeRangePicker.tsx
@@ -127,7 +127,7 @@ export function DateTimeRangePicker({
items,
}: DateTimeRangePickerProps) {
return (
-
+
)
}
diff --git a/app/table/Table.browser.spec.tsx b/app/table/Table.browser.spec.tsx
new file mode 100644
index 000000000..0215a2fa6
--- /dev/null
+++ b/app/table/Table.browser.spec.tsx
@@ -0,0 +1,60 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table'
+import { expect, test } from 'vitest'
+import { render } from 'vitest-browser-react'
+
+import { getMultiSelectCol } from './columns/select-col'
+import { Table } from './Table'
+
+type Item = { id: string; name: string }
+
+const data: Item[] = [
+ { id: '1', name: 'alpha' },
+ { id: '2', name: 'beta' },
+]
+const columns: ColumnDef- [] = [
+ getMultiSelectCol
- (),
+ { accessorKey: 'name', header: 'Name' },
+]
+
+function MultiSelectTable() {
+ const table = useReactTable({
+ columns,
+ data,
+ enableRowSelection: true,
+ getCoreRowModel: getCoreRowModel(),
+ getRowId: (row) => row.id,
+ })
+ return
+}
+
+test('selects a row by clicking its checkbox cell', async () => {
+ const screen = await render()
+ const row = screen.getByRole('row', { name: 'alpha' })
+ const headerCheckbox = screen.getByRole('checkbox').first()
+
+ await row.getByRole('cell').first().click()
+
+ await expect.element(row.getByRole('checkbox')).toBeChecked()
+ await expect.element(headerCheckbox).toBePartiallyChecked()
+})
+
+test('selects and clears every row from the header checkbox', async () => {
+ const screen = await render()
+ const checkboxes = screen.getByRole('checkbox')
+ const headerCheckbox = checkboxes.first()
+
+ await headerCheckbox.click()
+ await expect.element(checkboxes.nth(1)).toBeChecked()
+ await expect.element(checkboxes.nth(2)).toBeChecked()
+
+ await headerCheckbox.click()
+ await expect.element(checkboxes.nth(1)).not.toBeChecked()
+ await expect.element(checkboxes.nth(2)).not.toBeChecked()
+})
diff --git a/app/ui/lib/ActionMenu.browser.spec.tsx b/app/ui/lib/ActionMenu.browser.spec.tsx
new file mode 100644
index 000000000..256eb0789
--- /dev/null
+++ b/app/ui/lib/ActionMenu.browser.spec.tsx
@@ -0,0 +1,102 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { useState } from 'react'
+import { MemoryRouter } from 'react-router'
+import { expect, test } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+
+import { ActionMenu, type QuickActionItem } from './ActionMenu'
+
+const navigationItems: QuickActionItem[] = [
+ { value: 'db1', navGroup: 'Go to instance', action: '/instances/db1' },
+ { value: 'db2', navGroup: 'Go to instance', action: '/instances/db2' },
+]
+
+function ActionMenuHarness() {
+ const [isOpen, setIsOpen] = useState(true)
+ const [action, setAction] = useState('none')
+ const items: QuickActionItem[] = [
+ {
+ value: 'New instance',
+ navGroup: 'Actions',
+ action: () => setAction('New instance'),
+ },
+ ...navigationItems,
+ ]
+
+ return (
+
+
+
+ setIsOpen(false)}
+ />
+
+ )
+}
+
+test('navigates options with arrow keys and wraps at the ends', async () => {
+ const screen = await render()
+ const selected = screen.getByRole('option', { selected: true })
+
+ await expect.element(selected).toHaveTextContent('New instance')
+ await userEvent.keyboard('{ArrowDown}')
+ await expect.element(selected).toHaveTextContent('db1')
+ await userEvent.keyboard('{ArrowUp}{ArrowUp}')
+ await expect.element(selected).toHaveTextContent('db2')
+})
+
+test('filters items and resets the search when dismissed', async () => {
+ const screen = await render()
+ const search = screen.getByPlaceholder('Search')
+
+ await search.fill('db1')
+ await expect.element(screen.getByRole('option', { name: 'db1' })).toBeVisible()
+ await expect
+ .element(screen.getByRole('option', { name: 'New instance' }))
+ .not.toBeInTheDocument()
+
+ await userEvent.keyboard('{Escape}')
+ await expect
+ .element(screen.getByRole('dialog', { name: 'Quick actions' }))
+ .not.toBeInTheDocument()
+ await screen.getByRole('button', { name: 'Open quick actions' }).click()
+
+ await expect.element(search).toHaveValue('')
+ await expect.element(screen.getByRole('option', { name: 'New instance' })).toBeVisible()
+ await expect
+ .element(screen.getByRole('option', { selected: true }))
+ .toHaveTextContent('New instance')
+})
+
+test('invokes the selected action with Enter and dismisses the menu', async () => {
+ const screen = await render()
+
+ await userEvent.keyboard('{Enter}')
+
+ await expect.element(screen.getByText('Action: New instance')).toBeVisible()
+ await expect
+ .element(screen.getByRole('dialog', { name: 'Quick actions' }))
+ .not.toBeInTheDocument()
+})
+
+test('dismisses with Escape', async () => {
+ const screen = await render()
+
+ await userEvent.keyboard('{Escape}')
+
+ await expect
+ .element(screen.getByRole('dialog', { name: 'Quick actions' }))
+ .not.toBeInTheDocument()
+})
diff --git a/app/ui/lib/Combobox.browser.spec.tsx b/app/ui/lib/Combobox.browser.spec.tsx
new file mode 100644
index 000000000..1cefad19b
--- /dev/null
+++ b/app/ui/lib/Combobox.browser.spec.tsx
@@ -0,0 +1,231 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { useState } from 'react'
+import { expect, test } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { commands, userEvent } from 'vitest/browser'
+
+import { Combobox, toComboboxItems, type ComboboxItem } from './Combobox'
+
+declare module 'vitest/browser' {
+ interface BrowserCommands {
+ clickComboboxButton: (label: string) => Promise
+ pressComboboxKey: (label: string, key: string) => Promise
+ }
+}
+
+const items = toComboboxItems([{ name: 'disk-3' }, { name: 'disk-4' }])
+
+function ComboboxHarness({
+ comboboxItems = items,
+ initialValue = '',
+ allowArbitraryValues = false,
+}: {
+ comboboxItems?: ComboboxItem[]
+ initialValue?: string
+ allowArbitraryValues?: boolean
+}) {
+ const [selectedItemValue, setSelectedItemValue] = useState(initialValue)
+ const [parentEscapes, setParentEscapes] = useState(0)
+
+ return (
+ // The parent key handler models the modal form's Escape listener.
+ // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
+
+ )
+}
+
+test('preserves the committed selection while editing', async () => {
+ const screen = await render()
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await expect.element(combobox).toHaveValue('disk-3')
+
+ await combobox.fill('disk-3zzz')
+ await commands.pressComboboxKey('Disk name', 'ArrowDown')
+ await expect.element(combobox).toHaveValue('disk-3zzz')
+ await expect.element(screen.getByRole('option', { name: 'No items match' })).toBeVisible()
+
+ await screen.getByRole('button', { name: 'Outside' }).click()
+ await expect.element(combobox).toHaveValue('disk-3')
+})
+
+test('commits a different selected option after editing', async () => {
+ const screen = await render()
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await combobox.fill('disk-4')
+ await commands.pressComboboxKey('Disk name', 'ArrowDown')
+ await screen.getByRole('option', { name: 'disk-4' }).click()
+
+ await expect.element(combobox).toHaveValue('disk-4')
+})
+
+test('clears the committed selection when the input is emptied', async () => {
+ const screen = await render()
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await combobox.fill('disk-3')
+ await commands.pressComboboxKey('Disk name', 'ArrowDown')
+ await screen.getByRole('option', { name: 'disk-3' }).click()
+ await combobox.fill('')
+ await commands.pressComboboxKey('Disk name', 'Escape')
+
+ await expect.element(combobox).toHaveValue('')
+ await expect.element(screen.getByText('Selected: (empty)')).toBeVisible()
+})
+
+test('keeps arbitrary values and resets the query when cleared externally', async () => {
+ const screen = await render()
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await combobox.fill('d')
+ await commands.pressComboboxKey('Disk name', 'ArrowDown')
+ await expect.element(screen.getByRole('option', { name: 'Custom: d' })).toBeVisible()
+ await screen.getByRole('button', { name: 'Outside' }).click()
+ await expect.element(combobox).toHaveValue('d')
+ await expect.element(screen.getByText('Selected: d')).toBeVisible()
+
+ await screen.getByRole('button', { name: 'Clear externally' }).click()
+ await expect.element(combobox).toHaveValue('')
+ await commands.clickComboboxButton('Disk name')
+ await expect.element(combobox).toHaveAttribute('aria-expanded', 'true')
+ await expect.element(screen.getByRole('option', { name: 'disk-3' })).toBeVisible()
+ await expect
+ .element(screen.getByRole('option', { name: 'Custom: d' }))
+ .not.toBeInTheDocument()
+})
+
+test('Enter commits the highlighted option instead of the arbitrary query', async () => {
+ const comboboxItems = toComboboxItems([{ name: 'mock-vpc' }])
+ const screen = await render(
+
+ )
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await combobox.fill('mock')
+ await commands.pressComboboxKey('Disk name', 'ArrowDown')
+ const listedOption = screen.getByRole('option', { name: 'mock-vpc' })
+ await expect.element(listedOption).toBeVisible()
+ await expect.element(screen.getByRole('option', { name: 'Custom: mock' })).toBeVisible()
+ await screen.getByRole('button', { name: 'Outside' }).click()
+ await expect.element(combobox).toHaveAttribute('aria-expanded', 'false')
+ await commands.clickComboboxButton('Disk name')
+ await expect.element(combobox).toHaveAttribute('aria-expanded', 'true')
+ await commands.pressComboboxKey('Disk name', 'Home')
+ const listedOptionId = listedOption.element().id
+ await expect.element(combobox).toHaveAttribute('aria-activedescendant', listedOptionId)
+ await commands.pressComboboxKey('Disk name', 'Enter')
+
+ await expect.element(combobox).toHaveValue('mock-vpc')
+ await expect.element(screen.getByText('Selected: mock-vpc')).toBeVisible()
+})
+
+test('Escape closes the options before propagating to the parent', async () => {
+ const screen = await render()
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await combobox.fill('disk')
+ await commands.pressComboboxKey('Disk name', 'ArrowDown')
+ await expect.element(screen.getByRole('option', { name: 'disk-3' })).toBeVisible()
+ await userEvent.keyboard('{Escape}')
+ await expect.element(screen.getByRole('option')).not.toBeInTheDocument()
+ await expect.element(screen.getByText('Parent escapes: 0')).toBeVisible()
+
+ await userEvent.keyboard('{Escape}')
+ await expect.element(screen.getByText('Parent escapes: 1')).toBeVisible()
+})
+
+test('virtualizes and filters options outside the initial window', async () => {
+ const manyItems = toComboboxItems(
+ Array.from({ length: 1_000 }, (_, index) => ({
+ name: `disk-${String(index + 1).padStart(4, '0')}`,
+ }))
+ )
+ const screen = await render()
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await combobox.fill('disk-')
+ await commands.pressComboboxKey('Disk name', 'ArrowDown')
+ const firstOption = screen.getByRole('option', { name: 'disk-0001' })
+ await expect.element(firstOption).toBeVisible()
+ await expect.element(firstOption).toHaveAttribute('aria-setsize', '1000')
+ await expect
+ .element(screen.getByRole('option', { name: 'disk-0988' }))
+ .not.toBeInTheDocument()
+
+ await userEvent.keyboard('{End}')
+ await expect.element(screen.getByRole('option', { name: 'disk-0988' })).toBeVisible()
+
+ await combobox.fill('disk-0988')
+ await screen.getByRole('option', { name: 'disk-0988' }).click()
+ await expect.element(combobox).toHaveValue('disk-0988')
+ await expect.element(screen.getByRole('option')).not.toBeInTheDocument()
+})
+
+test('filters rich labels by their selected label and displays metadata', async () => {
+ const richItems: ComboboxItem[] = [
+ {
+ value: 'image-1',
+ selectedLabel: 'ubuntu-22-04',
+ label: (
+
+
ubuntu-22-04
+
Ubuntu / 22.04 / 4 GiB
+
+ ),
+ },
+ {
+ value: 'image-2',
+ selectedLabel: 'ubuntu-20-04',
+ label: (
+
+
ubuntu-20-04
+
Ubuntu / 20.04 / 2 GiB
+
+ ),
+ },
+ {
+ value: 'image-3',
+ selectedLabel: 'arch-2022-06-01',
+ label: arch-2022-06-01
,
+ },
+ ]
+ const screen = await render()
+ const combobox = screen.getByRole('combobox', { name: 'Disk name' })
+
+ await commands.clickComboboxButton('Disk name')
+ await expect.element(combobox).toHaveAttribute('aria-expanded', 'true')
+ await combobox.fill('ubuntu')
+ await expect.element(combobox).toHaveValue('ubuntu')
+ await expect.element(screen.getByText('Ubuntu / 22.04 / 4 GiB')).toBeVisible()
+ await expect
+ .element(screen.getByRole('option', { name: 'arch-2022-06-01' }))
+ .not.toBeInTheDocument()
+})
diff --git a/app/ui/lib/FileInput.browser.spec.tsx b/app/ui/lib/FileInput.browser.spec.tsx
new file mode 100644
index 000000000..29e4aa343
--- /dev/null
+++ b/app/ui/lib/FileInput.browser.spec.tsx
@@ -0,0 +1,54 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { useState } from 'react'
+import { expect, test } from 'vitest'
+import { render } from 'vitest-browser-react'
+import { userEvent } from 'vitest/browser'
+
+import { FileInput } from './FileInput'
+
+function FileInputHarness() {
+ const [changes, setChanges] = useState([])
+ return (
+ <>
+ setChanges((values) => [...values, file?.name ?? '(cleared)'])}
+ />
+
+ >
+ )
+}
+
+const image = new File(['hello'], 'stuff.png', { type: 'image/png' })
+
+test('uploads a real file and displays its metadata', async () => {
+ const screen = await render()
+ const input = screen.getByLabelText('Image file')
+
+ await userEvent.upload(input, image)
+
+ await expect.element(screen.getByText('stuff.png', { exact: true })).toBeVisible()
+ await expect.element(screen.getByText('(5 B)')).toBeVisible()
+ await expect.element(screen.getByText('Changes: stuff.png')).toBeVisible()
+})
+
+test('clears the native input so the same file can be selected again', async () => {
+ const screen = await render()
+ const input = screen.getByLabelText('Image file')
+
+ await userEvent.upload(input, image)
+ await screen.getByRole('button', { name: 'Clear file' }).click()
+ await expect.element(input).toHaveValue('')
+ await userEvent.upload(input, image)
+
+ await expect
+ .element(screen.getByText('Changes: stuff.png, (cleared), stuff.png'))
+ .toBeVisible()
+})
diff --git a/bot-notes/2026-07-10-vitest-browser-e2e-inventory-analysis.md b/bot-notes/2026-07-10-vitest-browser-e2e-inventory-analysis.md
new file mode 100644
index 000000000..28fa82ca9
--- /dev/null
+++ b/bot-notes/2026-07-10-vitest-browser-e2e-inventory-analysis.md
@@ -0,0 +1,84 @@
+# Vitest browser candidates inside the Playwright suite
+
+Date: 2026-07-10
+
+## Goal
+
+Inventory interaction sequences inside existing Playwright E2E tests that primarily exercise reusable UI behavior and would be clearer or faster as Vitest browser tests. Classify at the assertion/sequence level rather than assuming an entire E2E file must move together.
+
+## Classification criteria
+
+A sequence is a strong browser-test candidate when most of its setup exists only to reach one component state, its assertions concern local UI behavior, and route/API/session behavior is incidental. Retain Playwright coverage for representative wiring, persistence, authorization, navigation, loaders, and backend state transitions.
+
+## Working inventory
+
+The Chrome project currently lists 344 tests across 52 files. The useful migration unit is usually a sequence within a test, not the file: a successful mutation can remain E2E while its conditional fields, validation matrix, and dialog keyboard behavior move to a direct browser test.
+
+## Browser matrix
+
+Vitest's Playwright provider accepts multiple `browser.instances`, including Chromium, Firefox, and WebKit. Vitest shares one Vite server and dependency cache between the instances. See the [Vitest multiple setups documentation](https://vitest.dev/guide/browser/multiple-setups.html).
+
+Run all browser specs in all three engines. That preserves the cross-browser property of the Playwright suite and is currently cheap: the expanded 19-test suite takes 4.41 seconds wall-clock for all 57 browser/engine combinations. Instances run concurrently, so the cost is not 3×. The first matrix run also exposed click-only setup in a Combobox test that was unreliable in Firefox under concurrent load; removing the redundant setup interaction made the suite portable.
+
+## Tier 1: extract first
+
+These cases have little reason to pay for a complete route, seeded mock API, login, and page navigation.
+
+| E2E source | Move to browser tests | Keep in Playwright | Notes |
+| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `combobox.e2e.ts` | Committed versus edited value, clear, arbitrary values, highlighted Enter behavior, Escape containment, image labels, and virtualization | One representative submitted payload and one parent-form/modal wiring case | Eight direct tests now cover the component behavior. Most of this file reaches unrelated forms only to exercise `Combobox`. |
+| `docs-popover.e2e.ts` | Open/close, content and links, Escape/focus restoration, outside click | At most one page-level check that the expected docs are wired to a page | Already covered directly. |
+| `row-select.e2e.ts` | Cell selection, header select-all/clear, indeterminate state | Nothing unless a real product page later exposes selection-specific integration | The E2E case is skipped and explicitly points toward Testing Library; direct table tests now cover it. |
+| `action-menu.e2e.ts` | Form-safe Enter, arrow navigation, search, selected-item action, Escape/reset, and item grouping/order | Breadcrumb-derived “Go up” and the route/permission/resource-state registrations | Mount `ActionMenu` with a `MemoryRouter` and explicit items. |
+| `nav-guard-modal.e2e.ts` | Pristine dismiss, dirty dismiss confirmation, Keep editing, Leave form, Escape, and focus behavior | One navigation integration check if desired | The Floating IP page is incidental setup for generic `SideModalForm` behavior. |
+| `instance-metrics.e2e.ts` | Custom date range, invalid range, preset selection, keyboard dismissal | Metrics tab navigation, API loading/empty/error states, chart refetch | Add the two remaining cases to the existing `DateTimeRangePicker` browser spec. |
+| `dates.e2e.ts`; locale cases in `ip-pools.e2e.ts` | English/German/French date output and localized large-number output | No full-page locale test unless locale wiring itself has regressed | Use locale-specific browser instances or an explicit locale harness. Engine matrix remains three browsers; locale instances are orthogonal. |
+| `project-create.e2e.ts` | Expected fields, client validation, scrim dismiss, known mutation-error rendering/reset | One successful create/navigation and representative server error | A direct form test can mock the mutation boundary without an app page. |
+| `silos.e2e.ts` (`form scrolls...`) | Generic ObjectAlreadyExists-to-name-field focus/scroll behavior | Silo-specific server error mapping | Better placed on the shared form shell in a constrained viewport. |
+| `pagination.e2e.ts` | Previous/next disabled state, uncached spinner, cached navigation, row counts, and scroll reset | A small query-table route wiring check, if any | Use `useQueryTable` with a seeded query client and controllable deferred queries. |
+| `instance.e2e.ts` (polling/menu sequences) | Open row actions, rerender/poll new row data, assert menu remains open | One real polling integration case | Repeat across direct table/action-column variants rather than five full pages. |
+
+## Tier 2: form-state clusters
+
+These are valuable browser tests, but some require a query-client/router harness or a small extraction from a large form.
+
+| E2E source | Move to browser tests | Keep in Playwright | Prerequisite |
+| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
+| `firewall-rules.e2e.ts` | Targets table, target and host validation, hosts table, ICMPv4/v6 filter switching, add/remove rows, overflow tooltip/layout | Representative create/update/clone payloads, direct URL/404, conflicts | Mount the common rule editor with form context; mount the cell renderer separately for overflow. |
+| `external-subnets.e2e.ts` | Explicit CIDR validation, IPv4/IPv6 prefix clamp, auto/explicit field switching, resolved-name rendering | Create/update/delete, attach/detach, direct route | Seed pool/instance queries. |
+| `subnet-pools.e2e.ts` | Cross-field prefix validation and pool-picker filtering | Member mutation and link/default persistence | Existing pure validation tests remain useful; browser test covers RHF wiring. |
+| `ip-pools.e2e.ts` | Range validation/add/remove UI, IPv4-in-v6 and IPv6-in-v4 rejection, count/error clearing | Successful mutation, capacity/utilization persistence, silo links/defaults | Mount range form with pool version/data. |
+| `disks.e2e.ts` | Blank/snapshot/image/local source switching, size clamp, source minimum size, read-only state | A representative create per API shape and resulting resource | Seed image/snapshot queries or pass explicit options after a small extraction. |
+| `instance-create.e2e.ts` | Hardware presets/custom validation; boot-disk size/name/source state; additional-disk filtering/table; floating-IP add/remove/empty state; NIC/IP-version/ephemeral-IP dependencies; no-VPC disabled state | Representative successful creates and resulting network/storage payloads; server/default-pool errors | Highest payoff but largest harness. Prefer extracting form sections over mounting the whole route for every matrix case. |
+| `ip-pool-silo-config.e2e.ts` | Default/missing/no-pool form initialization and blocked-submit states | Successful instance/IP attachment and actual silo-default integration | Reuse the Instance Create and Floating IP form harnesses. |
+| `instance-networking.e2e.ts` | Ephemeral-IP option filtering by NIC version, attach-dialog disabled/error state, Transit IP local validation/editor | Representative attach/detach/update mutation | Seed instance/NIC/IP data. |
+| `image-upload.e2e.ts` | Form/block-size validation; progress-step rendering; cancel at each state; cancel-canceling; retry; failure-to-message mapping; nested scrim behavior | One real streaming upload, cleanup/persistence after cancel, one server failure | Make the upload state machine or transport injectable. The nested modal test should run in WebKit too rather than remain skipped there. |
+| `instance-disks.e2e.ts` (`Attach disk error clears...`) | Mutation error resets when the modal closes/reopens | One sentinel-backed attach failure | Directly tests the documented modal mutation-reset contract. |
+
+## Tier 3: targeted component matrices
+
+- `instance-auto-restart.e2e.ts`: move the policy/state/time-dependent popover content matrix to a direct test with fixed time and route params. Keep policy mutation, settings navigation, and persistence E2E.
+- `theme.e2e.ts`: move the picker, persisted preference, `matchMedia` response, and forced-dark hook/component behavior. Keep pre-hydration `theme-init.js` and serial-terminal color integration E2E because their value is document/application lifecycle integration.
+- `system-update.e2e.ts`: confirmation/warning content for release-state combinations can be a direct modal test; retain role gates, update workflow, backend transitions, and persistence.
+- `utilization.e2e.ts`: move zero-over-zero and number presentation to formatter/component tests. Keep role visibility, resource data, and copy/page wiring E2E.
+- Tooltip and copy-button assertions embedded in `fleet-access.e2e.ts`, `inventory.e2e.ts`, and resource tables should usually become one direct test of the shared renderer rather than repeated page-specific E2Es. Keep assertions that a page supplies the correct value.
+
+## Keep primarily E2E
+
+The remaining files principally test routing, authorization, authentication, loader/error-boundary behavior, API mutations and persistence, browser history/scroll restoration, CSP/application boot, or terminal integration. They should not be migrated merely because direct browser mounting is possible:
+
+- routing and shell: `breadcrumbs`, `click-everything`, `lookup-routes`, `meta`, `scroll-restore`, `error-pages`, `z-index`
+- authentication and authorization: `authz`, `login`, `login-saml`, access/role suites
+- resource workflows: `access-tokens`, `anti-affinity`, floating IP create/update, images, inventory, networking, network-interface create, project/silo access, SCIM tokens, snapshots, SSH keys, VPCs
+- terminal and application lifecycle: `instance-serial`, pre-hydration theme tests
+
+Those files can still shed small validation or reusable-widget sequences when encountered, but their central assertions need the assembled application and mock backend.
+
+## Recommended extraction order
+
+1. Finish direct coverage for Combobox, ActionMenu, SideModalForm/nav guard, DateTimeRangePicker, locale formatting, and row selection; remove the corresponding redundant or skipped E2E sequences.
+2. Extract the repeated firewall, external-subnet, IP/subnet-pool, and disk form-state matrices.
+3. Build reusable browser harnesses for QueryClient + MemoryRouter + form mutations, then tackle Instance Create and Instance Networking.
+4. Make image upload state injectable and move its long UI state matrix, retaining a small E2E transport/persistence set.
+
+This order proves the deletion side of the migration early. Adding browser tests without deleting the route-heavy duplicates would improve diagnostics but not CI time.
diff --git a/bot-notes/2026-07-10-vitest-browser-e2e-inventory-plan.md b/bot-notes/2026-07-10-vitest-browser-e2e-inventory-plan.md
new file mode 100644
index 000000000..0a0806503
--- /dev/null
+++ b/bot-notes/2026-07-10-vitest-browser-e2e-inventory-plan.md
@@ -0,0 +1,20 @@
+# Vitest browser extraction inventory
+
+Date: 2026-07-10
+
+## Plan
+
+1. [x] Enumerate all 344 Chrome Playwright tests across 52 files and identify component-like interaction sequences.
+2. [x] Inspect the strongest candidates and the reusable component/form each exercises.
+3. [x] Classify candidates by payoff and name the Playwright coverage to retain.
+4. [x] Identify prerequisite refactors or harness/provider needs.
+5. [x] Verify the browser matrix: run every browser spec in Chromium, Firefox, and WebKit.
+
+## Recommended implementation slices
+
+1. Complete ActionMenu, SideModalForm/nav guard, DateTimeRangePicker, locale-formatting, and row-selection coverage; delete replaced E2E sequences. Combobox component coverage is complete.
+2. Move local state/validation matrices for firewall rules, external subnets, IP/subnet pools, and disk creation.
+3. Add a shared QueryClient + MemoryRouter + mutation browser harness and use it for Instance Create/Networking state matrices.
+4. Inject image-upload stages/transport so the progress, cancellation, retry, and error UI can be tested directly while keeping a few transport/persistence E2Es.
+
+All browser tests should run in Chromium, Firefox, and WebKit. The current 19-test suite completes 57 browser/engine cases in 4.41 seconds. The six closest Chromium E2E cases for the new ActionMenu and DateTimeRangePicker coverage take 6.20 seconds by themselves.
diff --git a/package-lock.json b/package-lock.json
index 6931eb65a..c2fcf11bb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -70,6 +70,7 @@
"@types/uuid": "^10.0.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.2",
+ "@vitest/browser-playwright": "^4.1.5",
"autoprefixer": "^10.4.20",
"eslint-plugin-playwright": "^2.10.4",
"eslint-plugin-react-hook-form": "^0.3.1",
@@ -86,7 +87,8 @@
"typescript": "^7.0.2",
"vite": "^8.0.16",
"vite-node": "^6.0.0",
- "vitest": "^4.1.5"
+ "vitest": "^4.1.5",
+ "vitest-browser-react": "^2.2.0"
},
"engines": {
"node": ">=22"
@@ -315,6 +317,13 @@
}
}
},
+ "node_modules/@blazediff/core": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
+ "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -2107,6 +2116,13 @@
"node": ">=18"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
@@ -6228,6 +6244,53 @@
}
}
},
+ "node_modules/@vitest/browser": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.5.tgz",
+ "integrity": "sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@blazediff/core": "1.9.1",
+ "@vitest/mocker": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "magic-string": "^0.30.21",
+ "pngjs": "^7.0.0",
+ "sirv": "^3.0.2",
+ "tinyrainbow": "^3.1.0",
+ "ws": "^8.19.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.1.5"
+ }
+ },
+ "node_modules/@vitest/browser-playwright": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.5.tgz",
+ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/browser": "4.1.5",
+ "@vitest/mocker": "4.1.5",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "playwright": "*",
+ "vitest": "4.1.5"
+ },
+ "peerDependenciesMeta": {
+ "playwright": {
+ "optional": false
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
@@ -9504,6 +9567,16 @@
"integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==",
"license": "Apache-2.0 WITH LLVM-exception"
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10237,6 +10310,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/pngjs": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.19.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -11274,6 +11357,21 @@
"react": ">=16.8.0"
}
},
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -11583,6 +11681,16 @@
"node": ">=0.6"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
@@ -12245,6 +12353,31 @@
}
}
},
+ "node_modules/vitest-browser-react": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz",
+ "integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0",
+ "vitest": "^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -12357,6 +12490,28 @@
"license": "ISC",
"peer": true
},
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"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 3bbdfb181..5e9d4101d 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"ci": "npm run tsc && npm run lint && npm run fmt:check && npm run test run && npm run e2ec",
"tsc": "tsc",
"test": "NODE_OPTIONS='--no-deprecation' vitest",
+ "test:browser": "NODE_OPTIONS='--no-deprecation' vitest --project browser",
"update-snapshots": "npm test run -- -u",
"e2e": "playwright test",
"e2ec": "playwright test --project=chrome",
@@ -94,6 +95,7 @@
"@types/uuid": "^10.0.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.2",
+ "@vitest/browser-playwright": "^4.1.5",
"autoprefixer": "^10.4.20",
"eslint-plugin-playwright": "^2.10.4",
"eslint-plugin-react-hook-form": "^0.3.1",
@@ -110,7 +112,8 @@
"typescript": "^7.0.2",
"vite": "^8.0.16",
"vite-node": "^6.0.0",
- "vitest": "^4.1.5"
+ "vitest": "^4.1.5",
+ "vitest-browser-react": "^2.2.0"
},
"browserslist": [
"defaults"
diff --git a/test/browser/setup.ts b/test/browser/setup.ts
new file mode 100644
index 000000000..e7d8f69a2
--- /dev/null
+++ b/test/browser/setup.ts
@@ -0,0 +1,10 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+// Browser component tests need the same global styles as the application entry point.
+// eslint-disable-next-line no-restricted-imports
+import '~/ui/styles/index.css'
diff --git a/vite.config.ts b/vite.config.ts
index 1c747b23e..3bd34e1db 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,6 +13,7 @@ import tailwindcss from '@tailwindcss/vite'
import basicSsl from '@vitejs/plugin-basic-ssl'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
+import { configDefaults } from 'vitest/config'
import { z } from 'zod/v4'
import vercelConfig from './vercel.json'
@@ -160,8 +161,10 @@ export default defineConfig(({ mode }) => ({
resolve: { tsconfigPaths: true },
preview: { headers },
test: {
+ name: 'unit',
environment: 'jsdom',
setupFiles: ['test/unit/setup.ts'],
includeSource: ['app/**/*.ts'],
+ exclude: [...configDefaults.exclude, '**/*.browser.spec.{ts,tsx}'],
},
}))
diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts
new file mode 100644
index 000000000..22234d73d
--- /dev/null
+++ b/vitest.browser.config.ts
@@ -0,0 +1,52 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import tailwindcss from '@tailwindcss/vite'
+import react from '@vitejs/plugin-react'
+import { playwright } from '@vitest/browser-playwright'
+import { defineConfig } from 'vitest/config'
+import type { BrowserCommand } from 'vitest/node'
+
+const clickComboboxButton: BrowserCommand<[label: string]> = async (context, label) => {
+ await context.iframe.getByRole('combobox', { name: label }).locator('+ button').click()
+}
+
+const pressComboboxKey: BrowserCommand<[label: string, key: string]> = async (
+ context,
+ label,
+ key
+) => {
+ if (context.provider.name !== 'playwright') {
+ throw new Error(
+ `pressComboboxKey requires Playwright, received ${context.provider.name}`
+ )
+ }
+ await context.iframe.getByRole('combobox', { name: label }).press(key)
+}
+
+export default defineConfig({
+ optimizeDeps: {
+ entries: ['app/**/*.browser.spec.{ts,tsx}'],
+ include: ['react-router'],
+ },
+ plugins: [tailwindcss(), react()],
+ resolve: { tsconfigPaths: true },
+ test: {
+ attachmentsDir: 'test-results/vitest/attachments',
+ include: ['app/**/*.browser.spec.{ts,tsx}'],
+ name: 'browser',
+ setupFiles: ['test/browser/setup.ts'],
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright(),
+ commands: { clickComboboxButton, pressComboboxKey },
+ screenshotDirectory: 'test-results/vitest/screenshots',
+ instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }],
+ },
+ },
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..e9fa12b21
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,14 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ projects: ['./vite.config.ts', './vitest.browser.config.ts'],
+ },
+})