From 69e8681d8039986e9c5d7b9ac0113cb19e605311 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 2 Jun 2026 12:56:29 -0500 Subject: [PATCH 1/2] extract combobox e2e coverage --- test/e2e/combobox.e2e.ts | 246 ++++++++++++++++++++++++++++++++ test/e2e/firewall-rules.e2e.ts | 194 +++---------------------- test/e2e/instance-create.e2e.ts | 103 ------------- test/e2e/instance-disks.e2e.ts | 44 ------ test/e2e/utils.ts | 26 ++++ 5 files changed, 294 insertions(+), 319 deletions(-) create mode 100644 test/e2e/combobox.e2e.ts diff --git a/test/e2e/combobox.e2e.ts b/test/e2e/combobox.e2e.ts new file mode 100644 index 000000000..77c73e38d --- /dev/null +++ b/test/e2e/combobox.e2e.ts @@ -0,0 +1,246 @@ +/* + * 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 https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { + expect, + expectComboboxOptions, + expectRowVisible, + fillAndSelectComboboxOption, + selectOption, + test, + type Page, +} from './utils' + +async function openAttachDiskModal(page: Page) { + await page.goto('/projects/mock-project/instances/db-stopped') + await page.getByRole('button', { name: 'Attach existing disk' }).click() + return page.getByRole('dialog', { name: 'Attach disk' }) +} + +test('non-arbitrary combobox preserves selection while editing', async ({ page }) => { + const dialog = await openAttachDiskModal(page) + const dialogTitle = dialog.getByText('Attach disk', { exact: true }).first() + const combobox = dialog.getByRole('combobox', { name: 'Disk name' }) + + await combobox.click() + await page.getByRole('option', { name: 'disk-3' }).click() + await expect(combobox).toHaveValue('disk-3') + + await dialogTitle.click() + await combobox.click() + await combobox.press('End') + await combobox.pressSequentially('zzz') + await expect(combobox).toHaveValue('disk-3zzz') + await expect(page.getByRole('option', { name: 'disk-3', exact: true })).toBeHidden() + await expect(page.getByRole('option', { name: 'No items match' })).toBeVisible() + + await dialogTitle.click() + await expect(combobox).toHaveValue('disk-3') + + await combobox.click() + await combobox.press('End') + await combobox.press('Backspace') + await expect(combobox).toHaveValue('disk-') + await dialogTitle.click() + await expect(combobox).toHaveValue('disk-3') + + await dialog.getByRole('button', { name: 'Attach disk' }).click() + await expect(page.getByRole('cell', { name: 'disk-3' })).toBeVisible() +}) + +test('virtualized combobox filters and selects options outside the initial window', async ({ + page, +}) => { + await page.goto('/projects/other-project/instances-new') + + await page.getByRole('button', { name: 'Attach existing disk' }).click() + const combobox = page.getByPlaceholder('Select a disk') + await combobox.click() + + // The combobox virtualizes, so only the first visible options are mounted; + // aria-setsize reports the full attachable count. + await expect(page.getByRole('option', { name: 'disk-0005' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0007' })).toBeVisible() + await expect(page.getByRole('option').first()).toHaveAttribute('aria-setsize', /\d{2,}/) + + await expect(page.getByRole('option', { name: 'disk-0988' })).toBeHidden() + await combobox.press('End') + await expect(page.getByRole('option', { name: 'disk-0988' })).toBeVisible() + + await combobox.fill('disk-02') + await expect(page.getByRole('option', { name: 'disk-0023' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0125' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0211' })).toBeVisible() + await expect(page.getByRole('option', { name: 'disk-0220' })).toBeHidden() + await expect(page.getByRole('option', { name: 'disk-1000' })).toBeHidden() + + await combobox.fill('disk-0988') + await page.getByRole('option', { name: 'disk-0988' }).click() + await expect(page.getByRole('option')).toBeHidden() + await expect(page.getByRole('combobox', { name: 'Disk name' })).toHaveValue('disk-0988') + + await combobox.click() + await combobox.fill('asdf') + await expect(page.getByRole('option', { name: 'No items match' })).toBeVisible() +}) + +test('arbitrary-values combobox keeps typed values and resets submitted fields', async ({ + page, +}) => { + await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new') + + const vpcInput = page.getByRole('combobox', { name: 'VPC name' }).first() + await vpcInput.focus() + await expectComboboxOptions(page, ['mock-vpc']) + + await vpcInput.fill('d') + await expectComboboxOptions(page, ['Custom: d']) + + await vpcInput.blur() + await page.getByRole('button', { name: 'Add target' }).click() + await expect(vpcInput).toHaveValue('') + + await vpcInput.focus() + await expectComboboxOptions(page, ['mock-vpc']) + + await selectOption(page, 'Target type', 'Instance') + const instanceInput = page.getByRole('combobox', { name: 'Instance name' }) + + await instanceInput.focus() + await expectComboboxOptions(page, [ + 'db1', + 'you-fail', + 'not-there-yet', + 'instance-update-error', + 'db2', + 'db-stopped', + ]) + + await instanceInput.fill('d') + await expectComboboxOptions(page, [ + 'db1', + 'instance-update-error', + 'db2', + 'db-stopped', + 'Custom: d', + ]) + + await instanceInput.blur() + await expect(page.getByRole('option')).toBeHidden() + + await expect(instanceInput).toHaveValue('d') + await instanceInput.focus() + + await expectComboboxOptions(page, [ + 'db1', + 'instance-update-error', + 'db2', + 'db-stopped', + 'Custom: d', + ]) + + await selectOption(page, 'Protocol filters', 'ICMPv4') + await page.getByRole('combobox', { name: 'ICMPv4 type' }).pressSequentially('abc') + const error = page + .getByRole('dialog') + .getByText('ICMP type must be a number between 0 and 255') + await expect(error).toBeHidden() + await page.getByRole('button', { name: 'Add protocol filter' }).click() + await expect(error).toBeVisible() + + await selectOption(page, 'Protocol filters', 'ICMPv6') + await expect(page.getByRole('combobox', { name: 'ICMPv6 type' })).toHaveValue('') + await expect(error).toBeHidden() +}) + +test('combobox Enter selects highlighted item, not raw query', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new') + + const targets = page.getByRole('table', { name: 'Targets' }) + const vpcInput = page.getByRole('combobox', { name: 'VPC name' }).first() + + await vpcInput.fill('mock') + await expect(page.getByRole('option', { name: 'mock-vpc' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Custom: mock' })).toBeVisible() + + await page.keyboard.press('ArrowUp') + await page.keyboard.press('Enter') + + await expect(vpcInput).toHaveValue('mock-vpc') + await page.getByRole('button', { name: 'Add target' }).click() + await expectRowVisible(targets, { Type: 'vpc', Value: 'mock-vpc' }) +}) + +test("Escape in combobox doesn't close the parent form", async ({ page }) => { + await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new') + + await page.getByRole('textbox', { name: 'Name' }).fill('a') + + const confirmModal = page.getByRole('dialog', { name: 'Confirm navigation' }) + await expect(confirmModal).toBeHidden() + await page.keyboard.press('Escape') + await expect(confirmModal).toBeVisible() + await confirmModal.getByRole('button', { name: 'Keep editing' }).click() + await expect(confirmModal).toBeHidden() + + const formModal = page.getByRole('dialog', { name: 'Add firewall rule' }) + await expect(formModal).toBeVisible() + + const input = page.getByRole('combobox', { name: 'VPC name' }).first() + await input.focus() + + await expect(page.getByRole('option').first()).toBeVisible() + await expectComboboxOptions(page, ['mock-vpc']) + + await page.keyboard.press('Escape') + await expect(confirmModal).toBeHidden() + await expect(page.getByRole('option')).toBeHidden() + await expect(formModal).toBeVisible() + + await page.keyboard.press('Escape') + await expect(confirmModal).toBeVisible() +}) + +test('image combobox preserves selection when editing without committing', async ({ + page, +}) => { + const instanceName = 'test-instance' + + await page.goto('/projects/mock-project/instances-new') + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + + const imageCombobox = page.getByRole('combobox', { name: 'Image' }) + await imageCombobox.scrollIntoViewIfNeeded() + await imageCombobox.click() + await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible() + await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeVisible() + + await imageCombobox.fill('ubuntu') + await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible() + await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeHidden() + + await page.getByRole('option', { name: 'ubuntu-22-04' }).click() + await expect(imageCombobox).toHaveValue('ubuntu-22-04') + + await imageCombobox.press('Backspace') + await imageCombobox.press('Backspace') + await imageCombobox.press('Backspace') + await imageCombobox.press('Backspace') + await expect(imageCombobox).toHaveValue('ubuntu-2') + await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible() + await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeHidden() + + await page.getByRole('textbox', { name: 'Name', exact: true }).click() + await expect(imageCombobox).toHaveValue('ubuntu-22-04') + + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) +}) diff --git a/test/e2e/firewall-rules.e2e.ts b/test/e2e/firewall-rules.e2e.ts index cf0deda32..496fb2f7f 100644 --- a/test/e2e/firewall-rules.e2e.ts +++ b/test/e2e/firewall-rules.e2e.ts @@ -6,22 +6,15 @@ * Copyright Oxide Computer Company */ -import { expect, test, type Locator, type Page } from '@playwright/test' - -import { clickRowAction, expectRowVisible, selectOption } from './utils' - -/** - * Fill a combobox and click a dropdown option. Scrolls the combobox toward the - * center of the viewport first so the Floating UI anchored dropdown has room to - * render on-screen. Without this, Safari/WebKit can place the dropdown outside - * the viewport when the combobox is near the bottom of a tall form, causing - * Playwright's click to fail. - */ -async function fillAndSelect(input: Locator, page: Page, text: string, optionName: string) { - await input.evaluate((el) => el.scrollIntoView({ block: 'center' })) - await input.fill(text) - await page.getByRole('option', { name: optionName }).click() -} +import { + clickRowAction, + expect, + expectRowVisible, + fillAndSelectComboboxOption, + selectOption, + test, + type Locator, +} from './utils' const defaultRules = ['allow-internal-inbound', 'allow-ssh', 'allow-icmp'] @@ -179,12 +172,12 @@ test('firewall rule form targets table', async ({ page }) => { // add targets with overlapping names and types to test delete - await fillAndSelect(targetVpcNameField, page, 'abc', 'Custom: abc') + await fillAndSelectComboboxOption(targetVpcNameField, page, 'abc', 'Custom: abc') await addButton.click() await expectRowVisible(targets, { Type: 'vpc', Value: 'abc' }) // enter a VPC called 'mock-subnet', even if that doesn't make sense here, to test dropdown later - await fillAndSelect(targetVpcNameField, page, 'mock-subnet', 'mock-subnet') + await fillAndSelectComboboxOption(targetVpcNameField, page, 'mock-subnet', 'mock-subnet') await addButton.click() await expectRowVisible(targets, { Type: 'vpc', Value: 'mock-subnet' }) @@ -204,7 +197,7 @@ test('firewall rule form targets table', async ({ page }) => { // now add a subnet by entering text await selectOption(page, 'Target type', 'VPC subnet') // test that the name typed in is normalized - await fillAndSelect(subnetNameField, page, 'abc-123', 'Custom: abc-123') + await fillAndSelectComboboxOption(subnetNameField, page, 'abc-123', 'Custom: abc-123') await addButton.click() await expectRowVisible(targets, { Type: 'subnet', Value: 'abc-123' }) @@ -237,7 +230,7 @@ test('firewall rule form target validation', async ({ page }) => { // Enter invalid VPC name const vpcNameField = page.getByRole('combobox', { name: 'VPC name' }).first() - await fillAndSelect(vpcNameField, page, 'ab-', 'Custom: ab-') + await fillAndSelectComboboxOption(vpcNameField, page, 'ab-', 'Custom: ab-') await addButton.click() await expect(nameError).toBeVisible() @@ -301,7 +294,7 @@ test('firewall rule form host validation', async ({ page }) => { // Enter invalid VPC name const vpcNameField = page.getByRole('combobox', { name: 'VPC name' }).nth(1) - await fillAndSelect(vpcNameField, page, 'ab-', 'Custom: ab-') + await fillAndSelectComboboxOption(vpcNameField, page, 'ab-', 'Custom: ab-') await addButton.click() await expect(nameError).toBeVisible() @@ -368,11 +361,11 @@ test('firewall rule form hosts table', async ({ page }) => { // add hosts with overlapping names and types to test delete - await fillAndSelect(hostFiltersVpcNameField, page, 'abc', 'Custom: abc') + await fillAndSelectComboboxOption(hostFiltersVpcNameField, page, 'abc', 'Custom: abc') await addButton.click() await expectRowVisible(hosts, { Type: 'vpc', Value: 'abc' }) - await fillAndSelect(hostFiltersVpcNameField, page, 'def', 'Custom: def') + await fillAndSelectComboboxOption(hostFiltersVpcNameField, page, 'def', 'Custom: def') await addButton.click() await expectRowVisible(hosts, { Type: 'vpc', Value: 'def' }) @@ -383,7 +376,7 @@ test('firewall rule form hosts table', async ({ page }) => { await selectOption(page, 'Host type', 'VPC subnet') const subnetNameField2 = page.getByRole('combobox', { name: 'Subnet name' }) - await fillAndSelect(subnetNameField2, page, 'abc', 'Custom: abc') + await fillAndSelectComboboxOption(subnetNameField2, page, 'abc', 'Custom: abc') await addButton.click() await expectRowVisible(hosts, { Type: 'subnet', Value: 'abc' }) @@ -442,7 +435,7 @@ test('can update firewall rule', async ({ page }) => { // add a new ICMP protocol filter with type 3 and code 0 await selectOption(page, 'Protocol filters', 'ICMPv4') const icmpTypeField = page.getByRole('combobox', { name: 'ICMPv4 type' }) - await fillAndSelect(icmpTypeField, page, '3', '3 - Destination Unreachable') + await fillAndSelectComboboxOption(icmpTypeField, page, '3', '3 - Destination Unreachable') await page.getByRole('textbox', { name: 'ICMPv4 code' }).fill('0') await page.getByRole('button', { name: 'Add protocol' }).click() @@ -452,7 +445,7 @@ test('can update firewall rule', async ({ page }) => { // add host filter await selectOption(page, 'Host type', 'VPC subnet') const editSubnetField = page.getByRole('combobox', { name: 'Subnet name' }) - await fillAndSelect( + await fillAndSelectComboboxOption( editSubnetField, page, 'edit-filter-subnet', @@ -602,90 +595,6 @@ test('name conflict error on edit', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { Name: 'allow-icmp2', Priority: '37' }) }) -async function expectOptions(page: Page, options: string[]) { - const selector = page.getByRole('option') - await expect(selector).toHaveCount(options.length) - for (const option of options) { - await expect(page.getByRole('option', { name: option })).toBeVisible() - } -} - -test('arbitrary values combobox', async ({ page }) => { - await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new') - - // test for bug where we'd persist the d after add and only show 'Custom: d' - const vpcInput = page.getByRole('combobox', { name: 'VPC name' }).first() - await vpcInput.focus() - await expectOptions(page, ['mock-vpc']) - - await vpcInput.fill('d') - await expectOptions(page, ['Custom: d']) - - await vpcInput.blur() - await page.getByRole('button', { name: 'Add target' }).click() - await expect(vpcInput).toHaveValue('') - - await vpcInput.focus() - await expectOptions(page, ['mock-vpc']) // bug cause failure here - - // test keeping query around on blur - await selectOption(page, 'Target type', 'Instance') - const input = page.getByRole('combobox', { name: 'Instance name' }) - - await input.focus() - await expectOptions(page, [ - 'db1', - 'you-fail', - 'not-there-yet', - 'instance-update-error', - 'db2', - 'db-stopped', - ]) - - await input.fill('d') - await expectOptions(page, [ - 'db1', - 'instance-update-error', - 'db2', - 'db-stopped', - 'Custom: d', - ]) - - await input.blur() - await expect(page.getByRole('option')).toBeHidden() - - await expect(input).toHaveValue('d') - await input.focus() - - // same options show up after blur (there was a bug around this) - await expectOptions(page, [ - 'db1', - 'instance-update-error', - 'db2', - 'db-stopped', - 'Custom: d', - ]) - - // make sure typing in ICMP filter input actually updates the underlying value, - // triggering a validation error for bad input. without onInputChange binding - // the input value to the form value, this does not trigger an error because - // the form thinks the input is empyt. - await selectOption(page, 'Protocol filters', 'ICMPv4') - await page.getByRole('combobox', { name: 'ICMPv4 type' }).pressSequentially('abc') - const error = page - .getByRole('dialog') - .getByText('ICMP type must be a number between 0 and 255') - await expect(error).toBeHidden() - await page.getByRole('button', { name: 'Add protocol filter' }).click() - await expect(error).toBeVisible() - - // switching protocol clears the type value and its stale validation error, - // rather than leaving the error stranded on the now-empty field - await selectOption(page, 'Protocol filters', 'ICMPv6') - await expect(page.getByRole('combobox', { name: 'ICMPv6 type' })).toHaveValue('') - await expect(error).toBeHidden() -}) - test('can add ICMPv4 and ICMPv6 protocol filters', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new') @@ -697,7 +606,7 @@ test('can add ICMPv4 and ICMPv6 protocol filters', async ({ page }) => { await protocolListbox.click() await page.getByRole('option', { name: 'ICMPv4', exact: true }).click() const v4Type = page.getByRole('combobox', { name: 'ICMPv4 type' }) - await fillAndSelect(v4Type, page, '8', '8 - Echo Request') + await fillAndSelectComboboxOption(v4Type, page, '8', '8 - Echo Request') await page.getByRole('button', { name: 'Add protocol filter' }).click() await expectRowVisible(protocolTable, { Protocol: 'ICMPv4', Type: '8' }) @@ -706,7 +615,7 @@ test('can add ICMPv4 and ICMPv6 protocol filters', async ({ page }) => { await protocolListbox.click() await page.getByRole('option', { name: 'ICMPv6', exact: true }).click() const v6Type = page.getByRole('combobox', { name: 'ICMPv6 type' }) - await fillAndSelect(v6Type, page, '128', '128 - Echo Request') + await fillAndSelectComboboxOption(v6Type, page, '128', '128 - Echo Request') await page.getByRole('button', { name: 'Add protocol filter' }).click() await expectRowVisible(protocolTable, { Protocol: 'ICMPv6', Type: '128' }) @@ -724,67 +633,8 @@ test('can add ICMPv4 and ICMPv6 protocol filters', async ({ page }) => { // switching protocol type clears the previously selected ICMP type await protocolListbox.click() await page.getByRole('option', { name: 'ICMPv6', exact: true }).click() - await fillAndSelect(v6Type, page, '128', '128 - Echo Request') + await fillAndSelectComboboxOption(v6Type, page, '128', '128 - Echo Request') await protocolListbox.click() await page.getByRole('option', { name: 'ICMPv4', exact: true }).click() await expect(page.getByRole('combobox', { name: 'ICMPv4 type' })).toHaveValue('') }) - -// Regression test: when typing a partial match in an arbitrary-values combobox, -// arrowing to a dropdown option, and pressing Enter, the selected option's value -// should end up in the field — not the raw typed query. -test('combobox Enter selects highlighted item, not raw query', async ({ page }) => { - await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new') - - const targets = page.getByRole('table', { name: 'Targets' }) - const vpcInput = page.getByRole('combobox', { name: 'VPC name' }).first() - - // Type "mock" — dropdown shows mock-vpc and Custom: mock - await vpcInput.fill('mock') - await expect(page.getByRole('option', { name: 'mock-vpc' })).toBeVisible() - await expect(page.getByRole('option', { name: 'Custom: mock' })).toBeVisible() - - // Arrow up to highlight mock-vpc and press Enter to select it - await page.keyboard.press('ArrowUp') - await page.keyboard.press('Enter') - - // The selected value should be mock-vpc, not the raw query "mock" - await expect(vpcInput).toHaveValue('mock-vpc') - - // Verify it can be submitted to the targets table - await page.getByRole('button', { name: 'Add target' }).click() - await expectRowVisible(targets, { Type: 'vpc', Value: 'mock-vpc' }) -}) - -test("esc in combobox doesn't close form", async ({ page }) => { - await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new') - - // make form dirty so we can get the confirm modal on close attempts - await page.getByRole('textbox', { name: 'Name' }).fill('a') - - // make sure the confirm modal does pop up on esc when we're not in a combobox - const confirmModal = page.getByRole('dialog', { name: 'Confirm navigation' }) - await expect(confirmModal).toBeHidden() - await page.keyboard.press('Escape') - await expect(confirmModal).toBeVisible() - await confirmModal.getByRole('button', { name: 'Keep editing' }).click() - await expect(confirmModal).toBeHidden() - - const formModal = page.getByRole('dialog', { name: 'Add firewall rule' }) - await expect(formModal).toBeVisible() - - const input = page.getByRole('combobox', { name: 'VPC name' }).first() - await input.focus() - - await expect(page.getByRole('option').first()).toBeVisible() - await expectOptions(page, ['mock-vpc']) - - await page.keyboard.press('Escape') - // options are closed, but the whole form modal is not - await expect(confirmModal).toBeHidden() - await expect(page.getByRole('option')).toBeHidden() - await expect(formModal).toBeVisible() - // now press esc again to leave the form - await page.keyboard.press('Escape') - await expect(confirmModal).toBeVisible() -}) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 9628805a7..603ba893d 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -556,55 +556,6 @@ test('attach a floating IP section has Empty version when no floating IPs exist ).toBeVisible() }) -test('attaching additional disks allows for combobox filtering', async ({ page }) => { - await page.goto('/projects/other-project/instances-new') - - const attachExistingDiskButton = page.getByRole('button', { - name: 'Attach existing disk', - }) - const selectADisk = page.getByPlaceholder('Select a disk') - - await attachExistingDiskButton.click() - await selectADisk.click() - // several disks should be shown in the visible window. the combobox - // virtualizes, so only the first ~20 options live in the DOM at once; - // aria-setsize reports the full attachable count. - await expect(page.getByRole('option', { name: 'disk-0005' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0007' })).toBeVisible() - await expect(page.getByRole('option').first()).toHaveAttribute('aria-setsize', /\d{2,}/) - - // Pressing End jumps the active option to the last entry, which forces - // the virtualizer to mount it. disk-0988 is the last detached - // (attachable) disk in the seeded set and isn't in the DOM on first - // open — toBeHidden confirms we're genuinely virtualizing rather than - // just rendering everything. - await expect(page.getByRole('option', { name: 'disk-0988' })).toBeHidden() - await selectADisk.press('End') - await expect(page.getByRole('option', { name: 'disk-0988' })).toBeVisible() - - // type in a string to use as a filter - await selectADisk.fill('disk-02') - // only disks with that substring should be shown - await expect(page.getByRole('option', { name: 'disk-0023' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0125' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0211' })).toBeVisible() - await expect(page.getByRole('option', { name: 'disk-0220' })).toBeHidden() - await expect(page.getByRole('option', { name: 'disk-1000' })).toBeHidden() - - // filter down to a single late-in-the-list disk and select it - await selectADisk.fill('disk-0988') - await page.getByRole('option', { name: 'disk-0988' }).click() - - // now options hidden and only the selected one is visible in the button/input - await expect(page.getByRole('option')).toBeHidden() - await expect(page.getByRole('combobox', { name: 'Disk name' })).toHaveValue('disk-0988') - - // a random string should give a disabled option - await selectADisk.click() - await selectADisk.fill('asdf') - await expect(page.getByRole('option', { name: 'No items match' })).toBeVisible() -}) - test('create instance with additional disks', async ({ page }) => { const instanceName = 'more-disks' await page.goto('/projects/mock-project/instances-new') @@ -727,60 +678,6 @@ test('Validate CPU and RAM', async ({ page }) => { await expect(memMsg).toBeVisible() }) -test('preserves silo image selection when editing the input without committing', async ({ - page, -}) => { - const instanceName = 'test-instance' - - await page.goto('/projects/mock-project/instances-new') - await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - - const imageSelectCombobox = page.getByRole('combobox', { name: 'Image' }) - await imageSelectCombobox.scrollIntoViewIfNeeded() - - // Ensure the combobox is visible and has the expected options - await expect(imageSelectCombobox).toHaveValue('') - await imageSelectCombobox.click() - await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible() - await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible() - await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeVisible() - - // Filter the combobox for a particular silo image pattern - await imageSelectCombobox.fill('ubuntu') - - // Ensure that only show the options that match the filter are visible - await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible() - await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible() - await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeHidden() - - // Select an image - await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - await expect(imageSelectCombobox).toHaveValue('ubuntu-22-04') - - // Delete four characters from the end to reveal more options - await page.keyboard.press('Backspace') - await page.keyboard.press('Backspace') - await page.keyboard.press('Backspace') - await page.keyboard.press('Backspace') - - // While editing, the input reflects the in-progress query and the dropdown - // re-filters accordingly. The underlying selection is preserved until the - // user commits a different option. - await expect(imageSelectCombobox).toHaveValue('ubuntu-2') - await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible() - await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible() - await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeHidden() - - // Blur the field by clicking elsewhere; the previously-selected image is - // preserved (rather than cleared) since no new option was committed. - await page.getByRole('textbox', { name: 'Name', exact: true }).click() - await expect(imageSelectCombobox).toHaveValue('ubuntu-22-04') - - // Continue with instance creation using the preserved selection - await page.getByRole('button', { name: 'Create instance' }).click() - await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) -}) - test('create instance with IPv6-only networking', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index c0c311bfe..3ede1fd20 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -106,50 +106,6 @@ test('Attach disk', async ({ page }) => { await expectVisible(page, ['role=cell[name="disk-3"]']) }) -test('Combobox typing after select', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1') - await stopInstance(page) - - await page.getByRole('button', { name: 'Attach existing disk' }).click() - - const combobox = page.getByRole('combobox', { name: 'Disk name' }) - await combobox.click() - await page.getByRole('option', { name: 'disk-3' }).click() - await expect(combobox).toHaveValue('disk-3') - - // Click out, then click back in. - const dialogTitle = page - .getByRole('dialog') - .getByText('Attach disk', { exact: true }) - .first() - await dialogTitle.click() - await combobox.click() - - // Typing edits the visible input AND filters the dropdown in lockstep. - await combobox.press('End') - await combobox.pressSequentially('zzz') - await expect(combobox).toHaveValue('disk-3zzz') - await expect(page.getByRole('option', { name: 'disk-3', exact: true })).toBeHidden() - await expect(page.getByRole('option', { name: 'No items match' })).toBeVisible() - - // Blurring (closing the dropdown) without picking anything reverts the - // input to the still-selected label. The form value is unchanged. - await dialogTitle.click() - await expect(combobox).toHaveValue('disk-3') - - // Backspacing then blurring also reverts: selection is sticky. - await combobox.click() - await combobox.press('End') - await combobox.press('Backspace') - await expect(combobox).toHaveValue('disk-') - await dialogTitle.click() - await expect(combobox).toHaveValue('disk-3') - - // Submitting now uses the still-selected value. - await page.getByRole('button', { name: 'Attach disk' }).click() - await expect(page.getByRole('cell', { name: 'disk-3' })).toBeVisible() -}) - test('Create disk', async ({ page }) => { await page.goto('/projects/mock-project/instances/db-stopped') diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 0ee45106a..1d9e4c691 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -83,6 +83,32 @@ export async function fillNumberInput( .toBe(expectedValue) } +/** + * Fill a combobox and click a dropdown option. Scrolls the combobox toward the + * center of the viewport first so the Floating UI anchored dropdown has room to + * render on-screen. Without this, Safari/WebKit can place the dropdown outside + * the viewport when the combobox is near the bottom of a tall form, causing + * Playwright's click to fail. + */ +export async function fillAndSelectComboboxOption( + input: Locator, + page: Page, + text: string, + optionName: string +) { + await input.evaluate((el) => el.scrollIntoView({ block: 'center' })) + await input.fill(text) + await page.getByRole('option', { name: optionName }).click() +} + +export async function expectComboboxOptions(page: Page, options: string[]) { + const selector = page.getByRole('option') + await expect(selector).toHaveCount(options.length) + for (const option of options) { + await expect(page.getByRole('option', { name: option })).toBeVisible() + } +} + // Technically this has type AsymmetricMatcher, which is not exported by // Playwright and is (surprisingly) just Record. Rather than use // that, I think it's smarter to do the following in case they ever make the From 1b0e38ee43e1ac90bc862ef775639bc21f24b89e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 2 Jun 2026 13:11:31 -0500 Subject: [PATCH 2/2] add combobox e2e edge cases --- test/e2e/combobox.e2e.ts | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test/e2e/combobox.e2e.ts b/test/e2e/combobox.e2e.ts index 77c73e38d..4e05adc57 100644 --- a/test/e2e/combobox.e2e.ts +++ b/test/e2e/combobox.e2e.ts @@ -53,6 +53,65 @@ test('non-arbitrary combobox preserves selection while editing', async ({ page } await expect(page.getByRole('cell', { name: 'disk-3' })).toBeVisible() }) +test('non-arbitrary combobox commits a different selected option after editing', async ({ + page, +}) => { + const dialog = await openAttachDiskModal(page) + const combobox = dialog.getByRole('combobox', { name: 'Disk name' }) + + await combobox.click() + await page.getByRole('option', { name: 'disk-3' }).click() + await expect(combobox).toHaveValue('disk-3') + + await combobox.click() + await combobox.fill('disk-4') + await page.getByRole('option', { name: 'disk-4' }).click() + await expect(combobox).toHaveValue('disk-4') + + await dialog.getByRole('button', { name: 'Attach disk' }).click() + await expect(page.getByRole('cell', { name: 'disk-4' })).toBeVisible() +}) + +test('non-arbitrary combobox submit uses committed selection when edit is uncommitted', async ({ + page, +}) => { + const dialog = await openAttachDiskModal(page) + const combobox = dialog.getByRole('combobox', { name: 'Disk name' }) + + await combobox.click() + await page.getByRole('option', { name: 'disk-3' }).click() + await expect(combobox).toHaveValue('disk-3') + + await combobox.click() + await combobox.press('End') + await combobox.pressSequentially('zzz') + await expect(combobox).toHaveValue('disk-3zzz') + + await dialog.getByRole('button', { name: 'Attach disk' }).click() + await expect(page.getByRole('cell', { name: 'disk-3' })).toBeVisible() +}) + +test('non-arbitrary combobox clears committed selection when input is emptied', async ({ + page, +}) => { + const dialog = await openAttachDiskModal(page) + const combobox = dialog.getByRole('combobox', { name: 'Disk name' }) + const requiredError = dialog.getByText('Disk name is required') + + await combobox.click() + await page.getByRole('option', { name: 'disk-3' }).click() + await expect(combobox).toHaveValue('disk-3') + + await combobox.fill('') + await expect(combobox).toHaveValue('') + await page.keyboard.press('Escape') + await expect(page.getByRole('option')).toBeHidden() + + await expect(requiredError).toBeHidden() + await dialog.getByRole('button', { name: 'Attach disk' }).click() + await expect(requiredError).toBeVisible() +}) + test('virtualized combobox filters and selects options outside the initial window', async ({ page, }) => { @@ -244,3 +303,18 @@ test('image combobox preserves selection when editing without committing', async await page.getByRole('button', { name: 'Create instance' }).click() await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) }) + +test('image combobox commits a new option after editing an existing selection', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const imageCombobox = page.getByRole('combobox', { name: 'Image' }) + await fillAndSelectComboboxOption(imageCombobox, page, 'ubuntu', 'ubuntu-22-04') + await expect(imageCombobox).toHaveValue('ubuntu-22-04') + + await imageCombobox.click() + await imageCombobox.fill('ubuntu-20') + await page.getByRole('option', { name: 'ubuntu-20-04' }).click() + await expect(imageCombobox).toHaveValue('ubuntu-20-04') +})