From ddab17e8b2ffce2b597d836c1e6092100a418c3b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 10 Jul 2026 12:29:36 -0500 Subject: [PATCH 1/7] Investigate Vitest browser mode --- app/ui/lib/Combobox.browser.spec.tsx | 82 ++++++++++++++ package-lock.json | 157 ++++++++++++++++++++++++++- package.json | 5 +- test/browser/setup.ts | 10 ++ vite.config.ts | 2 + vitest.browser.config.ts | 28 +++++ 6 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 app/ui/lib/Combobox.browser.spec.tsx create mode 100644 test/browser/setup.ts create mode 100644 vitest.browser.config.ts diff --git a/app/ui/lib/Combobox.browser.spec.tsx b/app/ui/lib/Combobox.browser.spec.tsx new file mode 100644 index 000000000..318015721 --- /dev/null +++ b/app/ui/lib/Combobox.browser.spec.tsx @@ -0,0 +1,82 @@ +/* + * 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 { Combobox, toComboboxItems, type ComboboxItem } from './Combobox' + +const items = toComboboxItems([{ name: 'disk-3' }, { name: 'disk-4' }]) + +function ComboboxHarness({ comboboxItems = items }: { comboboxItems?: ComboboxItem[] }) { + const [selectedItemValue, setSelectedItemValue] = useState('') + + return ( + <> + + + + ) +} + +test('preserves the committed selection while editing', async () => { + const screen = await render() + const combobox = screen.getByRole('combobox', { name: 'Disk name' }) + + await combobox.click() + await screen.getByRole('option', { name: 'disk-3' }).click() + await expect.element(combobox).toHaveValue('disk-3') + + await combobox.fill('disk-3zzz') + 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.click() + await screen.getByRole('option', { name: 'disk-3' }).click() + await combobox.fill('disk-4') + await screen.getByRole('option', { name: 'disk-4' }).click() + + await expect.element(combobox).toHaveValue('disk-4') +}) + +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.click() + await expect.element(screen.getByRole('option', { name: 'disk-0001' })).toBeVisible() + 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') +}) 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..49cc89a20 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 --config vitest.browser.config.ts", "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..cab584afa 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' @@ -163,5 +164,6 @@ export default defineConfig(({ mode }) => ({ 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..91171bdfc --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,28 @@ +/* + * 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' + +export default defineConfig({ + plugins: [tailwindcss(), react()], + resolve: { tsconfigPaths: true }, + test: { + attachmentsDir: 'test-results/vitest/attachments', + include: ['app/**/*.browser.spec.{ts,tsx}'], + setupFiles: ['test/browser/setup.ts'], + browser: { + enabled: true, + headless: true, + provider: playwright(), + screenshotDirectory: 'test-results/vitest/screenshots', + instances: [{ browser: 'chromium' }], + }, + }, +}) From 8a7ccc4e1d7093c13aff4642bf8827f3da96c3ec Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 10 Jul 2026 13:06:00 -0500 Subject: [PATCH 2/7] Expand Vitest browser mode benchmark --- app/components/DocsPopover.browser.spec.tsx | 66 ++++++++++++++++++ .../DateTimeRangePicker.browser.spec.tsx | 68 +++++++++++++++++++ .../form/fields/DateTimeRangePicker.tsx | 4 +- app/table/Table.browser.spec.tsx | 60 ++++++++++++++++ app/ui/lib/FileInput.browser.spec.tsx | 54 +++++++++++++++ vitest.browser.config.ts | 3 + 6 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 app/components/DocsPopover.browser.spec.tsx create mode 100644 app/components/form/fields/DateTimeRangePicker.browser.spec.tsx create mode 100644 app/table/Table.browser.spec.tsx create mode 100644 app/ui/lib/FileInput.browser.spec.tsx 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..8c3500162 --- /dev/null +++ b/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx @@ -0,0 +1,68 @@ +/* + * 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 { 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 } + +function DateTimeRangePickerHarness() { + const [preset, setPreset] = useState('lastDay') + const [selectedRange, setRange] = useState<{ start: DateValue; end: DateValue }>(range) + return ( + <> + + Preset: {preset} + + ) +} + +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() +}) 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/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)'])} + /> + Changes: {changes.join(', ') || '(none)'} + + ) +} + +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/vitest.browser.config.ts b/vitest.browser.config.ts index 91171bdfc..cb9a79989 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -11,6 +11,9 @@ import { playwright } from '@vitest/browser-playwright' import { defineConfig } from 'vitest/config' export default defineConfig({ + optimizeDeps: { + entries: ['app/**/*.browser.spec.{ts,tsx}'], + }, plugins: [tailwindcss(), react()], resolve: { tsconfigPaths: true }, test: { From f64705f3f4fd71040e579eef0ca9d9d59aa8fb6e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 10 Jul 2026 13:29:16 -0500 Subject: [PATCH 3/7] Unify Vitest projects --- .github/workflows/lintBuildTest.yml | 5 ++++- package.json | 2 +- vite.config.ts | 1 + vitest.browser.config.ts | 1 + vitest.config.ts | 14 ++++++++++++++ 5 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 vitest.config.ts diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index 385572875..e428abc6f 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -51,7 +51,7 @@ 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: @@ -95,6 +95,9 @@ jobs: - name: 🏗 Install Playwright OS dependencies if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps + - name: Run Vitest browser tests + if: matrix.browser == 'chrome' + run: npm run test:browser -- run - name: Run Playwright browser tests run: npx playwright test --project=${{matrix.browser}} - uses: actions/upload-artifact@v7 diff --git a/package.json b/package.json index 49cc89a20..5e9d4101d 100644 --- a/package.json +++ b/package.json @@ -19,7 +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 --config vitest.browser.config.ts", + "test:browser": "NODE_OPTIONS='--no-deprecation' vitest --project browser", "update-snapshots": "npm test run -- -u", "e2e": "playwright test", "e2ec": "playwright test --project=chrome", diff --git a/vite.config.ts b/vite.config.ts index cab584afa..3bd34e1db 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -161,6 +161,7 @@ export default defineConfig(({ mode }) => ({ resolve: { tsconfigPaths: true }, preview: { headers }, test: { + name: 'unit', environment: 'jsdom', setupFiles: ['test/unit/setup.ts'], includeSource: ['app/**/*.ts'], diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index cb9a79989..2e977c1be 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ test: { attachmentsDir: 'test-results/vitest/attachments', include: ['app/**/*.browser.spec.{ts,tsx}'], + name: 'browser', setupFiles: ['test/browser/setup.ts'], browser: { enabled: true, 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'], + }, +}) From d3401a690532eccd42a0675e54adae6af514493f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 10 Jul 2026 13:47:04 -0500 Subject: [PATCH 4/7] Inventory Vitest browser test candidates --- .../DateTimeRangePicker.browser.spec.tsx | 56 +++++++++- app/ui/lib/ActionMenu.browser.spec.tsx | 102 ++++++++++++++++++ app/ui/lib/Combobox.browser.spec.tsx | 20 ++-- vitest.browser.config.ts | 3 +- 4 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 app/ui/lib/ActionMenu.browser.spec.tsx diff --git a/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx b/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx index 8c3500162..2c07be760 100644 --- a/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx +++ b/app/components/form/fields/DateTimeRangePicker.browser.spec.tsx @@ -5,7 +5,12 @@ * * Copyright Oxide Computer Company */ -import { parseDateTime, type DateValue } from '@internationalized/date' +import { + getLocalTimeZone, + now, + parseDateTime, + type DateValue, +} from '@internationalized/date' import { useState } from 'react' import { expect, test } from 'vitest' import { render } from 'vitest-browser-react' @@ -15,10 +20,15 @@ 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() { +function DateTimeRangePickerHarness({ + initialRange = range, +}: { + initialRange?: { start: DateValue; end: DateValue } +}) { const [preset, setPreset] = useState('lastDay') - const [selectedRange, setRange] = useState<{ start: DateValue; end: DateValue }>(range) + const [selectedRange, setRange] = useState(initialRange) return ( <> 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/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 ( + + + Action: {action} + 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 index 318015721..b8c1a9508 100644 --- a/app/ui/lib/Combobox.browser.spec.tsx +++ b/app/ui/lib/Combobox.browser.spec.tsx @@ -14,8 +14,14 @@ import { Combobox, toComboboxItems, type ComboboxItem } from './Combobox' const items = toComboboxItems([{ name: 'disk-3' }, { name: 'disk-4' }]) -function ComboboxHarness({ comboboxItems = items }: { comboboxItems?: ComboboxItem[] }) { - const [selectedItemValue, setSelectedItemValue] = useState('') +function ComboboxHarness({ + comboboxItems = items, + initialValue = '', +}: { + comboboxItems?: ComboboxItem[] + initialValue?: string +}) { + const [selectedItemValue, setSelectedItemValue] = useState(initialValue) return ( <> @@ -31,11 +37,9 @@ function ComboboxHarness({ comboboxItems = items }: { comboboxItems?: ComboboxIt } test('preserves the committed selection while editing', async () => { - const screen = await render() + const screen = await render() const combobox = screen.getByRole('combobox', { name: 'Disk name' }) - await combobox.click() - await screen.getByRole('option', { name: 'disk-3' }).click() await expect.element(combobox).toHaveValue('disk-3') await combobox.fill('disk-3zzz') @@ -47,11 +51,9 @@ test('preserves the committed selection while editing', async () => { }) test('commits a different selected option after editing', async () => { - const screen = await render() + const screen = await render() const combobox = screen.getByRole('combobox', { name: 'Disk name' }) - await combobox.click() - await screen.getByRole('option', { name: 'disk-3' }).click() await combobox.fill('disk-4') await screen.getByRole('option', { name: 'disk-4' }).click() @@ -67,7 +69,7 @@ test('virtualizes and filters options outside the initial window', async () => { const screen = await render() const combobox = screen.getByRole('combobox', { name: 'Disk name' }) - await combobox.click() + await combobox.fill('disk-') await expect.element(screen.getByRole('option', { name: 'disk-0001' })).toBeVisible() await expect .element(screen.getByRole('option', { name: 'disk-0988' })) diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 2e977c1be..81cfcff89 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -13,6 +13,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ optimizeDeps: { entries: ['app/**/*.browser.spec.{ts,tsx}'], + include: ['react-router'], }, plugins: [tailwindcss(), react()], resolve: { tsconfigPaths: true }, @@ -26,7 +27,7 @@ export default defineConfig({ headless: true, provider: playwright(), screenshotDirectory: 'test-results/vitest/screenshots', - instances: [{ browser: 'chromium' }], + instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], }, }, }) From 0413033a3a6078354791f2df6e10581323078ef7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 10 Jul 2026 13:59:31 -0500 Subject: [PATCH 5/7] Add Vitest browser migration notes --- ...0-vitest-browser-e2e-inventory-analysis.md | 84 +++++++++++++++++++ ...07-10-vitest-browser-e2e-inventory-plan.md | 20 +++++ 2 files changed, 104 insertions(+) create mode 100644 bot-notes/2026-07-10-vitest-browser-e2e-inventory-analysis.md create mode 100644 bot-notes/2026-07-10-vitest-browser-e2e-inventory-plan.md 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. From b7b41842a480c74c8c990b3f969065c548ff9e7e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 10 Jul 2026 14:12:34 -0500 Subject: [PATCH 6/7] improve combobox tests --- app/ui/lib/Combobox.browser.spec.tsx | 152 ++++++++++++++++++++++++++- vitest.browser.config.ts | 15 +++ 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/app/ui/lib/Combobox.browser.spec.tsx b/app/ui/lib/Combobox.browser.spec.tsx index b8c1a9508..cf229651c 100644 --- a/app/ui/lib/Combobox.browser.spec.tsx +++ b/app/ui/lib/Combobox.browser.spec.tsx @@ -8,31 +8,55 @@ import { useState } from 'react' import { expect, test } from 'vitest' import { render } from 'vitest-browser-react' -import { userEvent } from 'vitest/browser' +import { commands, userEvent } from 'vitest/browser' import { Combobox, toComboboxItems, type ComboboxItem } from './Combobox' +declare module 'vitest/browser' { + interface BrowserCommands { + 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 +
event.preventDefault()} + onKeyDown={(event) => { + if (event.key === 'Escape') setParentEscapes((count) => count + 1) + }} + > + - + Selected: {selectedItemValue || '(empty)'} + Parent escapes: {parentEscapes} + ) } @@ -43,6 +67,7 @@ test('preserves the committed selection while editing', async () => { 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() @@ -55,11 +80,83 @@ test('commits a different selected option after editing', async () => { 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 combobox.click() + 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 commands.pressComboboxKey('Disk name', 'ArrowUp') + 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) => ({ @@ -70,7 +167,10 @@ test('virtualizes and filters options outside the initial window', async () => { const combobox = screen.getByRole('combobox', { name: 'Disk name' }) await combobox.fill('disk-') - await expect.element(screen.getByRole('option', { name: 'disk-0001' })).toBeVisible() + 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() @@ -81,4 +181,48 @@ test('virtualizes and filters options outside the initial window', async () => { 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 combobox.fill('ubuntu') + await commands.pressComboboxKey('Disk name', 'ArrowDown') + await expect.element(screen.getByText('Ubuntu / 22.04 / 4 GiB')).toBeVisible() + await expect + .element(screen.getByRole('option', { name: 'arch-2022-06-01' })) + .not.toBeInTheDocument() + await screen.getByRole('option', { name: /ubuntu-22-04/ }).click() + + await expect.element(combobox).toHaveValue('ubuntu-22-04') + await expect.element(screen.getByText('Selected: image-1')).toBeVisible() }) diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 81cfcff89..1c741d90c 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -9,6 +9,20 @@ 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 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: { @@ -26,6 +40,7 @@ export default defineConfig({ enabled: true, headless: true, provider: playwright(), + commands: { pressComboboxKey }, screenshotDirectory: 'test-results/vitest/screenshots', instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], }, From ea50e168cc74ca42af7fffc6a5fd65e6216a4404 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 10 Jul 2026 15:53:21 -0500 Subject: [PATCH 7/7] clean up CI action config --- .github/workflows/lintBuildTest.yml | 17 +++++++++++------ app/ui/lib/Combobox.browser.spec.tsx | 17 ++++++++++------- vitest.browser.config.ts | 6 +++++- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index e428abc6f..20b104eb3 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -55,14 +55,20 @@ jobs: - 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 @@ -96,13 +102,12 @@ jobs: if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps - name: Run Vitest browser tests - if: matrix.browser == 'chrome' - run: npm run test:browser -- run + 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/ui/lib/Combobox.browser.spec.tsx b/app/ui/lib/Combobox.browser.spec.tsx index cf229651c..1cefad19b 100644 --- a/app/ui/lib/Combobox.browser.spec.tsx +++ b/app/ui/lib/Combobox.browser.spec.tsx @@ -14,6 +14,7 @@ import { Combobox, toComboboxItems, type ComboboxItem } from './Combobox' declare module 'vitest/browser' { interface BrowserCommands { + clickComboboxButton: (label: string) => Promise pressComboboxKey: (label: string, key: string) => Promise } } @@ -113,7 +114,7 @@ test('keeps arbitrary values and resets the query when cleared externally', asyn await screen.getByRole('button', { name: 'Clear externally' }).click() await expect.element(combobox).toHaveValue('') - await combobox.click() + 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 @@ -133,7 +134,11 @@ test('Enter commits the highlighted option instead of the arbitrary query', asyn const listedOption = screen.getByRole('option', { name: 'mock-vpc' }) await expect.element(listedOption).toBeVisible() await expect.element(screen.getByRole('option', { name: 'Custom: mock' })).toBeVisible() - await commands.pressComboboxKey('Disk name', 'ArrowUp') + 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') @@ -215,14 +220,12 @@ test('filters rich labels by their selected label and displays metadata', async 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 commands.pressComboboxKey('Disk name', 'ArrowDown') + 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() - await screen.getByRole('option', { name: /ubuntu-22-04/ }).click() - - await expect.element(combobox).toHaveValue('ubuntu-22-04') - await expect.element(screen.getByText('Selected: image-1')).toBeVisible() }) diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 1c741d90c..22234d73d 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -11,6 +11,10 @@ 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, @@ -40,7 +44,7 @@ export default defineConfig({ enabled: true, headless: true, provider: playwright(), - commands: { pressComboboxKey }, + commands: { clickComboboxButton, pressComboboxKey }, screenshotDirectory: 'test-results/vitest/screenshots', instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], },