Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .github/workflows/lintBuildTest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
66 changes: 66 additions & 0 deletions app/components/DocsPopover.browser.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button type="button">Outside</button>
<DocsPopover
heading="disks"
icon={<Document16Icon />}
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(<DocsPopoverHarness />)

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(<DocsPopoverHarness />)
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(<DocsPopoverHarness />)

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()
})
118 changes: 118 additions & 0 deletions app/components/form/fields/DateTimeRangePicker.browser.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<RangeKeyAll>('lastDay')
const [selectedRange, setRange] = useState(initialRange)
return (
<>
<DateTimeRangePicker
preset={preset}
setPreset={setPreset}
range={selectedRange}
setRange={setRange}
/>
<output>Preset: {preset}</output>
</>
)
}

test('chooses a preset with the pointer', async () => {
const screen = await render(<DateTimeRangePickerHarness />)

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(<DateTimeRangePickerHarness />)
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(<DateTimeRangePickerHarness />)
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(
<DateTimeRangePickerHarness
initialRange={{ start: current.subtract({ hours: 1 }), end: current }}
/>
)

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(
<DateTimeRangePickerHarness
initialRange={{ start: current.subtract({ hours: 1 }), end: current }}
/>
)

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()
})
4 changes: 2 additions & 2 deletions app/components/form/fields/DateTimeRangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export function DateTimeRangePicker({
items,
}: DateTimeRangePickerProps) {
return (
<form className="flex">
<div className="flex">
<Listbox
className="z-10 w-40 [&_button]:rounded-r-none! [&_button]:border-r-0!"
name="preset"
Expand Down Expand Up @@ -156,6 +156,6 @@ export function DateTimeRangePicker({
className="[&_.rounded-l]:rounded-l-none!"
/>
</div>
</form>
</div>
)
}
60 changes: 60 additions & 0 deletions app/table/Table.browser.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<Item>[] = [
getMultiSelectCol<Item>(),
{ accessorKey: 'name', header: 'Name' },
]

function MultiSelectTable() {
const table = useReactTable({
columns,
data,
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return <Table aria-label="Items" table={table} multiSelect />
}

test('selects a row by clicking its checkbox cell', async () => {
const screen = await render(<MultiSelectTable />)
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(<MultiSelectTable />)
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()
})
Loading
Loading