diff --git a/AGENTS.md b/AGENTS.md index be17d56fe..838c84d6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,7 @@ - Reach for primitives in `app/ui` before inventing page-specific widgets; that directory holds router-agnostic building blocks. - When you just need Tailwind classes on a DOM element, use the `classed` helper instead of creating one-off wrappers (`app/util/classed.ts`). +- Define helper components at the module level, not inside other components' render functions—the `react/no-unstable-nested-components` eslint rule enforces this to prevent performance issues and broken component identity. Extract nested components to the top level and pass any needed values as props. - Reuse utility components for consistent formatting—`TimeAgo`, `EmptyMessage`, `CardBlock`, `DocsPopover`, `PropertiesTable`, etc. - Import icons from `@oxide/design-system/icons/react` with size suffixes: `16` for inline/table, `24` for headers/buttons, `12` for tiny indicators. - Keep help URLs in `links`/`docLinks` (`app/util/links.ts`). diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index f18e42965..f5cede7f9 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -54,6 +54,10 @@ type IpPoolSelectorProps< /** Compatible IP versions based on network interface type */ compatibleVersions?: IpVersion[] required?: boolean + hideOptionalTag?: boolean + label?: string + /** Hide visible label, using it as aria-label instead */ + hideLabel?: boolean } export function IpPoolSelector< @@ -67,6 +71,9 @@ export function IpPoolSelector< disabled = false, compatibleVersions = ALL_IP_VERSIONS, required = true, + hideOptionalTag = false, + label = 'Pool', + hideLabel = false, }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component const sortedPools = useMemo(() => { @@ -84,12 +91,14 @@ export function IpPoolSelector< ) diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx index 445c8e810..b4947038b 100644 --- a/app/components/form/fields/ListboxField.tsx +++ b/app/components/form/fields/ListboxField.tsx @@ -26,6 +26,8 @@ export type ListboxFieldProps< placeholder?: string className?: string label?: string + /** Hide visible label, using it as aria-label instead */ + hideLabel?: boolean required?: boolean description?: string | React.ReactNode control: Control @@ -54,6 +56,7 @@ export function ListboxField< isLoading, noItemsPlaceholder, hideOptionalTag, + hideLabel, }: ListboxFieldProps) { // TODO: recreate this logic // validate: (v) => (required && !v ? `${name} is required` : undefined), @@ -63,6 +66,7 @@ export function ListboxField< - // Pool for ephemeral IP selection - ephemeralIpPool: string - assignEphemeralIp: boolean + // Ephemeral IP fields (dual stack support) + ephemeralIpv4: boolean + ephemeralIpv4Pool: string + ephemeralIpv6: boolean + ephemeralIpv6Pool: string // Selected floating IPs to attach on create. floatingIps: NameOrId[] @@ -215,8 +216,10 @@ const baseDefaultValues: InstanceCreateInput = { start: true, userData: null, - ephemeralIpPool: '', - assignEphemeralIp: false, + ephemeralIpv4: false, + ephemeralIpv4Pool: '', + ephemeralIpv6: false, + ephemeralIpv6Pool: '', floatingIps: [], } @@ -239,6 +242,110 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { export const handle = { crumb: 'New instance' } +const EPHEMERAL_IP_FIELDS = { + v4: { + checkboxName: 'ephemeralIpv4', + poolFieldName: 'ephemeralIpv4Pool', + displayVersion: 'IPv4', + }, + v6: { + checkboxName: 'ephemeralIpv6', + poolFieldName: 'ephemeralIpv6Pool', + displayVersion: 'IPv6', + }, +} as const + +function EphemeralIpCheckbox({ + control, + ipVersion, + compatibleVersions, + unicastPools, + isSubmitting, +}: { + control: Control + ipVersion: IpVersion + compatibleVersions: IpVersion[] + unicastPools: UnicastIpPool[] + isSubmitting: boolean +}) { + const { checkboxName, poolFieldName, displayVersion } = EPHEMERAL_IP_FIELDS[ipVersion] + const ephemeralIpField = useController({ control, name: checkboxName }) + const ephemeralIpPoolField = useController({ control, name: poolFieldName }) + const checked = ephemeralIpField.field.value + + const pools = useMemo( + () => unicastPools.filter((pool) => pool.ipVersion === ipVersion), + [unicastPools, ipVersion] + ) + const isCompatible = compatibleVersions.includes(ipVersion) + const hasPools = pools.length > 0 + const canAttach = isCompatible && hasPools + let disabledReason: React.ReactNode + if (!canAttach) { + disabledReason = isCompatible ? ( + <> + No IP{ipVersion} pools available +
+ for this instance’s network interfaces + + ) : ( + <> + Add an IP{ipVersion} network interface +
+ to attach an ephemeral IP{ipVersion} address + + ) + } + + // Track previous canAttach to detect false→true transitions (NIC type + // change re-enabling this IP version). A ref because we need to compare + // across renders without triggering re-renders when we update it. + const prevCanAttachRef = useRef(undefined) + useEffect(() => { + if (checked && !canAttach) { + ephemeralIpField.field.onChange(false) + ephemeralIpPoolField.field.onChange('') + } else if (canAttach && prevCanAttachRef.current === false && !checked) { + const defaultPool = pools.find((p) => p.isDefault) + if (defaultPool) { + ephemeralIpField.field.onChange(true) + ephemeralIpPoolField.field.onChange(defaultPool.name) + } + } + prevCanAttachRef.current = canAttach + }, [canAttach, checked, pools, ephemeralIpField, ephemeralIpPoolField]) + + return ( +
+ }> + {/* span makes tooltip show on label hover, not just the checkbox */} + + + Allocate {displayVersion} address + {checked && ' from pool:'} + + + +
+ +
+
+ ) +} + export default function CreateInstanceForm() { const [isSubmitting, setIsSubmitting] = useState(false) const { project } = useProjectSelector() @@ -317,10 +424,10 @@ export default function CreateInstanceForm() { const compatibleDefaultPools = unicastPools .filter(poolHasIpVersion(defaultCompatibleVersions)) .filter((p) => p.isDefault) - // TODO: when we switch to dual stack ephemeral IPs, this will need to change - // to handle selecting default pools for both v4 and v6 - const defaultEphemeralIpPool = - compatibleDefaultPools.length > 0 ? compatibleDefaultPools[0].name : '' + + // Get default pools for initial values + const defaultV4Pool = compatibleDefaultPools.find((p) => p.ipVersion === 'v4') + const defaultV6Pool = compatibleDefaultPools.find((p) => p.ipVersion === 'v6') const defaultValues: InstanceCreateInput = { ...baseDefaultValues, @@ -328,8 +435,10 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - ephemeralIpPool: defaultEphemeralIpPool || '', - assignEphemeralIp: !!defaultEphemeralIpPool, + ephemeralIpv4: !!defaultV4Pool && defaultCompatibleVersions.includes('v4'), + ephemeralIpv4Pool: defaultV4Pool?.name || '', + ephemeralIpv6: !!defaultV6Pool && defaultCompatibleVersions.includes('v6'), + ephemeralIpv6Pool: defaultV6Pool?.name || '', floatingIps: [], } @@ -441,22 +550,22 @@ export default function CreateInstanceForm() { const bootDisk = getBootDiskAttachment(values, allImages) - const assignEphemeralIp = values.assignEphemeralIp - const ephemeralIpPool = values.ephemeralIpPool - - const externalIps: ExternalIpCreate[] = values.floatingIps.map((floatingIp) => ({ - type: 'floating' as const, - floatingIp, - })) - - if (assignEphemeralIp) { + const externalIps: ExternalIpCreate[] = [] + if (values.ephemeralIpv4) { + externalIps.push({ + type: 'ephemeral', + poolSelector: { type: 'explicit', pool: values.ephemeralIpv4Pool }, + }) + } + if (values.ephemeralIpv6) { externalIps.push({ type: 'ephemeral', - // form validation is meant to ensure that pool is set when - // assignEphemeralIp checkbox is checked - poolSelector: { type: 'explicit', pool: ephemeralIpPool }, + poolSelector: { type: 'explicit', pool: values.ephemeralIpv6Pool }, }) } + for (const floatingIp of values.floatingIps) { + externalIps.push({ type: 'floating', floatingIp }) + } const userData = values.userData ? await readBlobAsBase64(values.userData) @@ -778,14 +887,9 @@ const NetworkingSection = ({ const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) const [selectedFloatingIp, setSelectedFloatingIp] = useState() - const assignEphemeralIpField = useController({ control, name: 'assignEphemeralIp' }) const floatingIpsField = useController({ control, name: 'floatingIps' }) - const assignEphemeralIp = assignEphemeralIpField.field.value - const attachedFloatingIps = floatingIpsField.field.value ?? EMPTY_NAME_OR_ID_LIST - - const ephemeralIpPoolField = useController({ control, name: 'ephemeralIpPool' }) - const ephemeralIpPool = ephemeralIpPoolField.field.value + const attachedFloatingIpNames = floatingIpsField.field.value ?? EMPTY_NAME_OR_ID_LIST // Calculate compatible IP versions based on NIC type const compatibleVersions = useMemo( @@ -798,104 +902,27 @@ const NetworkingSection = ({ q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) - // Filter out the IPs that are already attached to an instance - const attachableFloatingIps = useMemo( - () => floatingIpList.items.filter((ip) => !ip.instanceId), - [floatingIpList] - ) - - // To find available floating IPs, we remove the ones that are already committed to this instance - // and filter by IP version compatibility with configured NICs - const availableFloatingIps = useMemo(() => { - return attachableFloatingIps - .filter((ip) => !attachedFloatingIps.includes(ip.name)) + // Derive attached+available lists from one indexed pass to avoid repeated + // lookups + const { attachedFloatingIps, availableFloatingIps } = useMemo(() => { + // Filter out the IPs that are already attached to an instance + const attachableFloatingIps = floatingIpList.items.filter((ip) => !ip.instanceId) + const attachedNames = new Set(attachedFloatingIpNames) + const attachableByName = new Map( + attachableFloatingIps.map((ip) => [ip.name, ip] as const) + ) + const attachedFloatingIps = attachedFloatingIpNames + .map((name) => attachableByName.get(name)) + .filter((ip) => !!ip) + + // To find available floating IPs, remove the ones already committed to this + // instance and filter by IP version compatibility with configured NICs. + const availableFloatingIps = attachableFloatingIps + .filter((ip) => !attachedNames.has(ip.name)) .filter(ipHasVersion(compatibleVersions)) - }, [attachableFloatingIps, attachedFloatingIps, compatibleVersions]) - - const attachedFloatingIpsData = attachedFloatingIps - .map((floatingIp) => attachableFloatingIps.find((fip) => fip.name === floatingIp)) - .filter((ip) => !!ip) - - // Filter unicast pools by compatible IP versions - // unicastPools is already sorted (defaults first, v4 first, then by name), - // so filtering preserves that order - const compatiblePools = useMemo( - () => unicastPools.filter(poolHasIpVersion(compatibleVersions)), - [unicastPools, compatibleVersions] - ) - - useEffect(() => { - if (!assignEphemeralIp || compatiblePools.length === 0) return - - const currentPoolValid = - ephemeralIpPool && compatiblePools.some((p) => p.name === ephemeralIpPool) - if (currentPoolValid) return - - const defaultPool = compatiblePools.find((p) => p.isDefault) - if (defaultPool) { - ephemeralIpPoolField.field.onChange(defaultPool.name) - } else { - ephemeralIpPoolField.field.onChange('') - } - }, [assignEphemeralIp, ephemeralIpPool, ephemeralIpPoolField, compatiblePools]) - - // Track previous ability to attach ephemeral IP to detect transitions - const prevCanAttachRef = useRef(undefined) - - // Automatically manage ephemeral IP based on NIC and pool availability - useEffect(() => { - const hasCompatibleNics = compatibleVersions.length > 0 - const hasPools = compatiblePools.length > 0 - const canAttach = hasCompatibleNics && hasPools - const hasDefaultPool = compatiblePools.some((p) => p.isDefault) - const prevCanAttach = prevCanAttachRef.current - - if (!canAttach && assignEphemeralIp) { - // Remove ephemeral IP when there are no compatible NICs or pools - assignEphemeralIpField.field.onChange(false) - } else if ( - canAttach && - hasDefaultPool && - prevCanAttach === false && - !assignEphemeralIp - ) { - // Add ephemeral IP when transitioning from unable to able to attach - // (prevCanAttach === false means we couldn't attach before, either due to no NICs or no pools) - assignEphemeralIpField.field.onChange(true) - } - - prevCanAttachRef.current = canAttach - }, [assignEphemeralIp, assignEphemeralIpField, compatiblePools, compatibleVersions]) - const ephemeralIpCheckboxState = useMemo(() => { - const hasCompatibleNics = compatibleVersions.length > 0 - const hasCompatiblePools = compatiblePools.length > 0 - const canAttachEphemeralIp = hasCompatibleNics && hasCompatiblePools - - let disabledReason: React.ReactNode = undefined - if (!hasCompatibleNics) { - disabledReason = ( - <> - Add a compatible network interface -
- to attach an ephemeral IP address - - ) - } else if (!hasCompatiblePools) { - // TODO: "compatible" not clear enough. also this can happen if there are - // no pools at all as well as when there are no pools compatible withe - // the NIC stack. We could do a different messages for each. - disabledReason = ( - <> - No compatible IP pools available -
- for this network interface type - - ) - } - - return { canAttachEphemeralIp, disabledReason } - }, [compatibleVersions, compatiblePools]) + return { attachedFloatingIps, availableFloatingIps } + }, [floatingIpList.items, attachedFloatingIpNames, compatibleVersions]) const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) @@ -950,38 +977,22 @@ const NetworkingSection = ({ - } - > - {/* TODO: Wrapping the checkbox in a makes it so the tooltip - * shows up when you hover anywhere on the label or checkbox, not - * just the checkbox itself. The downside is the placement of the tooltip - * is a little weird (I'd like it better if it was anchored to the checkbox), - * but I think having it show up on label hover is worth it. - */} - - { - assignEphemeralIpField.field.onChange(!assignEphemeralIp) - }} - > - Allocate and attach an ephemeral IP address - - - - +
+ + +
@@ -1004,7 +1015,7 @@ const NetworkingSection = ({
item.name }, { header: 'IP', cell: (item) => item.ip }, diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index a292195a8..1b419123c 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -36,7 +36,9 @@ export interface ListboxProps { disabled?: boolean hasError?: boolean name?: string - label?: React.ReactNode + label?: string + /** Hide visible label, using it as aria-label on the button instead */ + hideLabel?: boolean description?: React.ReactNode required?: boolean isLoading?: boolean @@ -63,6 +65,7 @@ export const Listbox = ({ buttonRef, hideOptionalTag, hideSelected = false, + hideLabel = false, ...props }: ListboxProps) => { const selectedItem = selected && items.find((i) => i.value === selected) @@ -83,7 +86,7 @@ export const Listbox = ({ > {({ open }) => (
- {label && ( + {label && !hideLabel && (
({ hideSelected ? 'w-auto' : 'w-full' )} ref={buttonRef} + aria-label={hideLabel ? label : undefined} {...props} > {!hideSelected && ( diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ebdbcb632..ab2d17c21 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -64,35 +64,26 @@ test('can create an instance', async ({ page }) => { // hostname field should not exist await expectNotVisible(page, ['role=textbox[name="Hostname"]']) - const checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) - const poolDropdown = page.getByLabel('Pool') - - // verify that the ephemeral IP checkbox is checked and pool dropdown is visible - await expect(checkbox).toBeChecked() - await expect(poolDropdown).toBeVisible() - // IPv4 default pool should be selected by default - await expect(poolDropdown).toContainText('ip-pool-1') + // verify that the IPv4 ephemeral IP checkbox is checked by default + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeChecked() - // click the dropdown to open it and verify options are available - await poolDropdown.click() - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() + // IPv4 default pool should be selected + const v4PoolDropdown = page.getByLabel('IPv4 pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') - // unchecking the box should hide the pool selector - await checkbox.uncheck() - await expect(poolDropdown).toBeHidden() - - // re-checking the box should re-enable the selector, and other options should be selectable - await checkbox.check() - // Need to wait for the dropdown to be visible first - await expect(poolDropdown).toBeVisible() - // Click the dropdown to open it and wait for options to be available - await poolDropdown.click() - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() - // Force click since there might be overlays - await page.getByRole('option', { name: 'ip-pool-2' }).click({ force: true }) + // IPv6 default pool should be selected + const v6PoolDropdown = page.getByLabel('IPv6 pool') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() await expect(page.getByLabel('User data')).toBeVisible() @@ -138,15 +129,48 @@ test('can create an instance', async ({ page }) => { test('ephemeral pool selection tracks network interface IP version', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - await expect(poolDropdown).toContainText('ip-pool-1') + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', + }) + + // Default NIC is dual-stack, both checkboxes should be visible, enabled, and checked + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() + // Change to IPv6-only NIC - v4 checkbox should become disabled and unchecked await selectOption(page, page.getByRole('button', { name: 'IPv4 & IPv6' }), 'IPv6') - await expect(poolDropdown).toContainText('ip-pool-2') - - await selectOption(page, page.getByRole('button', { name: 'IPv6' }), 'IPv4') - await expect(poolDropdown).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() + + // Verify disabled v4 checkbox shows tooltip + await v4Checkbox.hover() + await expect(page.getByText('Add an IPv4 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv4 address')).toBeVisible() + + // Change to IPv4-only NIC - v6 checkbox should become disabled and unchecked + await selectOption(page, page.getByRole('button', { name: 'IPv6', exact: true }), 'IPv4') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() + + // Verify disabled v6 checkbox shows tooltip + await v6Checkbox.hover() + await expect(page.getByText('Add an IPv6 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv6 address')).toBeVisible() }) test('duplicate instance name produces visible error', async ({ page }) => { @@ -434,9 +458,10 @@ test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-ephemeral-ip') await selectAProjectImage(page, 'image-1') - await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) - .uncheck() + // Uncheck both ephemeral IP checkboxes + await page.getByRole('checkbox', { name: 'Allocate IPv4 address' }).uncheck() + await page.getByRole('checkbox', { name: 'Allocate IPv6 address' }).uncheck() + await page.getByRole('button', { name: 'Create instance' }).click() await expect(page).toHaveURL('/projects/mock-project/instances/no-ephemeral-ip/storage') await expect(page.getByText('External IPs—')).toBeVisible() @@ -846,27 +871,42 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4' nicTable.getByRole('cell', { name: 'my-ipv4-nic', exact: true }) ).toBeVisible() - // Verify that ephemeral IP options are constrained to IPv4 only - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify that only IPv4 ephemeral IP is enabled + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) - await expect(ephemeralCheckbox).toBeVisible() - - // Pool dropdown should be visible - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - // IPv4 default pool should be selected by default - await expect(poolDropdown).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() - // Open dropdown to check available options - IPv6 pools should be filtered out - await poolDropdown.click() + // IPv4 pool dropdown should be visible with default selected + const v4PoolDropdown = page.getByLabel('IPv4 pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') - // ip-pool-1 is IPv4, should appear + // Open dropdown to check available options - only IPv4 pools should appear + await v4PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + // Close dropdown to avoid obscuring subsequent interactions + await page.keyboard.press('Escape') + + // Create the instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) - // ip-pool-2 is IPv6, should NOT appear - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() + // Verify exactly one ephemeral IP row exists, and it is IPv4 + await page.getByRole('tab', { name: 'Networking' }).click() + const externalIpsTable = page.getByRole('table', { name: /external ips/i }) + const ephemeralRows = externalIpsTable.getByRole('row').filter({ hasText: 'ephemeral' }) + await expect(ephemeralRows).toHaveCount(1) + await expect(externalIpsTable.getByText('v4')).toBeVisible() + await expect(externalIpsTable.getByText('v6')).toBeHidden() }) test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6', async ({ @@ -907,27 +947,42 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6' nicTable.getByRole('cell', { name: 'my-ipv6-nic', exact: true }) ).toBeVisible() - // Verify that ephemeral IP options are constrained to IPv6 only - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify that only IPv6 ephemeral IP is enabled + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) - await expect(ephemeralCheckbox).toBeVisible() - - // Pool dropdown should be visible - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - // IPv6 default pool should be selected by default - await expect(poolDropdown).toContainText('ip-pool-2') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() - // Open dropdown to check available options - IPv4 pools should be filtered out - await poolDropdown.click() + // IPv6 pool dropdown should be visible with default selected + const v6PoolDropdown = page.getByLabel('IPv6 pool') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') - // ip-pool-2 is IPv6, should appear + // Open dropdown to check available options - only IPv6 pools should appear + await v6PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + // Close dropdown to avoid obscuring subsequent interactions + await page.keyboard.press('Escape') - // ip-pool-1 is IPv4, should NOT appear - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() + // Create the instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + + // Verify exactly one ephemeral IP row exists, and it is IPv6 + await page.getByRole('tab', { name: 'Networking' }).click() + const externalIpsTable = page.getByRole('table', { name: /external ips/i }) + const ephemeralRows = externalIpsTable.getByRole('row').filter({ hasText: 'ephemeral' }) + await expect(ephemeralRows).toHaveCount(1) + await expect(externalIpsTable.getByText('v4')).toBeHidden() + await expect(externalIpsTable.getByText('v6')).toBeVisible() }) test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephemeral IPs', async ({ @@ -968,25 +1023,45 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem nicTable.getByRole('cell', { name: 'my-dual-stack-nic', exact: true }) ).toBeVisible() - // Verify that both IPv4 and IPv6 ephemeral IP options are available - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify that both IPv4 and IPv6 ephemeral IP checkboxes are available + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) - await expect(ephemeralCheckbox).toBeVisible() - // Pool dropdown should be visible - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeChecked() - // IPv4 default pool should be selected by default (first in sorted order) - await expect(poolDropdown).toContainText('ip-pool-1') + // Both pool dropdowns should be visible with defaults selected + const v4PoolDropdown = page.getByLabel('IPv4 pool') + const v6PoolDropdown = page.getByLabel('IPv6 pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') - // Open dropdown to check available options - both IPv4 and IPv6 pools should be available - await poolDropdown.click() + // Create the instance + await page.getByRole('button', { name: 'Create instance' }).click() - // Both pools should appear - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + // Should navigate to instance page + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Verify two ephemeral IP rows exist in the external IPs table + const externalIpsTable = page.getByRole('table', { name: /external ips/i }) + const ephemeralRows = externalIpsTable.getByRole('row').filter({ hasText: 'ephemeral' }) + + await expect(ephemeralRows).toHaveCount(2) + + // Verify one is IPv4 and one is IPv6 + await expect(externalIpsTable.getByText('v4')).toBeVisible() + await expect(externalIpsTable.getByText('v6')).toBeVisible() }) test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) => { @@ -998,8 +1073,11 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) // Configure networking - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) const defaultRadio = page.getByRole('radio', { name: 'Default', @@ -1008,25 +1086,47 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }).first() - // Verify default state: "Default" is checked and Ephemeral IP checkbox is checked + // Verify default state: "Default" is checked and both ephemeral IP checkboxes are visible, enabled, and checked await expect(defaultRadio).toBeChecked() - await expect(ephemeralCheckbox).toBeChecked() - await expect(ephemeralCheckbox).toBeEnabled() - - // Select "None" radio → verify Ephemeral IP checkbox is unchecked and disabled + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() + + // Select "None" radio → verify ephemeral IP checkboxes are disabled and unchecked await noneRadio.click() - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() - - // Hover over the disabled checkbox to verify tooltip appears - await ephemeralCheckbox.hover() - await expect(page.getByText('Add a compatible network interface')).toBeVisible() - await expect(page.getByText('to attach an ephemeral IP address')).toBeVisible() - - // Select "Custom" radio → verify Ephemeral IP checkbox is still unchecked and disabled + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() + + // Verify tooltip shows disabled reason for IPv4 + await v4Checkbox.hover() + await expect(page.getByText('Add an IPv4 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv4 address')).toBeVisible() + + // Verify tooltip shows disabled reason for IPv6 + await v6Checkbox.hover() + await expect(page.getByText('Add an IPv6 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv6 address')).toBeVisible() + + // Select "Custom" radio → verify ephemeral IP checkboxes are still disabled and unchecked await customRadio.click() - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() + + // Verify tooltip still shows disabled reason when in Custom mode with no NICs + await v4Checkbox.hover() + await expect(page.getByText('Add an IPv4 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv4 address')).toBeVisible() // Click "Add network interface" button to open modal await page.getByRole('button', { name: 'Add network interface' }).click() @@ -1054,9 +1154,12 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true }) ).toBeVisible() - // Verify Ephemeral IP checkbox is now checked and enabled - await expect(ephemeralCheckbox).toBeChecked() - await expect(ephemeralCheckbox).toBeEnabled() + // Verify IPv4 ephemeral IP checkbox is now enabled and checked (auto-enabled when NIC added) + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() // Delete the NIC using the remove button await page.getByRole('button', { name: 'remove network interface new-v4-nic' }).click() @@ -1064,9 +1167,13 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) // Verify the NIC is no longer in the table await expect(nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true })).toBeHidden() - // Verify Ephemeral IP checkbox is once again unchecked and disabled - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() + // Verify ephemeral IP checkboxes are disabled and unchecked again + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() }) test('network interface options disabled when no VPCs exist', async ({ page }) => { diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index ade1a66db..4cad91ad1 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -343,9 +343,7 @@ test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { await page.getByRole('option', { name: 'IPv4', exact: true }).click() // Don't attach ephemeral IP at creation - await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) - .uncheck() + await page.getByRole('checkbox', { name: 'Allocate IPv4 address' }).uncheck() // Create instance await page.getByRole('button', { name: 'Create instance' }).click() @@ -398,9 +396,7 @@ test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { await page.getByRole('option', { name: 'IPv6', exact: true }).click() // Don't attach ephemeral IP at creation - await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) - .uncheck() + await page.getByRole('checkbox', { name: 'Allocate IPv6 address' }).uncheck() // Create instance await page.getByRole('button', { name: 'Create instance' }).click() @@ -453,9 +449,7 @@ test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { await page.getByRole('option', { name: 'IPv4', exact: true }).click() // Don't attach ephemeral IP at creation - await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) - .uncheck() + await page.getByRole('checkbox', { name: 'Allocate IPv4 address' }).uncheck() // Create instance await page.getByRole('button', { name: 'Create instance' }).click() @@ -502,9 +496,7 @@ test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { await page.getByRole('option', { name: 'IPv6', exact: true }).click() // Don't attach ephemeral IP at creation - await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) - .uncheck() + await page.getByRole('checkbox', { name: 'Allocate IPv6 address' }).uncheck() // Create instance await page.getByRole('button', { name: 'Create instance' }).click() diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 6d68085f7..62e395317 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -37,24 +37,32 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - // Verify ephemeral IP checkbox is checked by default - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify IPv4 ephemeral IP checkbox is checked by default + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) - await expect(ephemeralCheckbox).toBeChecked() - // Pool dropdown should be visible with IPv4 pool preselected - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - await expect(poolDropdown).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeChecked() + // v6 checkbox should be visible but not checked (no v6 default in myriad silo) + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeDisabled() + await v6Checkbox.hover() + await expect(page.getByText('No IPv6 pools available')).toBeVisible() + + // IPv4 pool dropdown should be visible with default pool preselected + const v4PoolDropdown = page.getByLabel('IPv4 pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') // Open dropdown to verify available options (only v4 pools should be available) - await poolDropdown.click() + await v4PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() await expect(page.getByRole('option', { name: 'ip-pool-3' })).toBeVisible() - // IPv6 pools should not be available in this silo - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() - await expect(page.getByRole('option', { name: 'ip-pool-4' })).toBeHidden() }) test('floating IP create form shows IPv4 default pool preselected', async ({ @@ -81,11 +89,11 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await page.getByRole('option', { name: 'ubuntu-22-04' }).click() // Verify ephemeral IP defaults - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', }) - await expect(ephemeralCheckbox).toBeChecked() - await expect(page.getByLabel('Pool')).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeChecked() + await expect(page.getByLabel('IPv4 pool')).toContainText('ip-pool-1') // Create instance await page.getByRole('button', { name: 'Create instance' }).click() @@ -209,24 +217,32 @@ test.describe('IP pool configuration: thrax silo (v6-only default)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - // Verify ephemeral IP checkbox is checked by default - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify IPv6 ephemeral IP checkbox is checked by default + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) - await expect(ephemeralCheckbox).toBeChecked() - // Pool dropdown should be visible with IPv6 pool preselected - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - await expect(poolDropdown).toContainText('ip-pool-2') + // v4 checkbox should be visible but not checked (no v4 default in thrax silo) + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).not.toBeChecked() + await expect(v4Checkbox).toBeDisabled() + await v4Checkbox.hover() + await expect(page.getByText('No IPv4 pools available')).toBeVisible() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeChecked() + + // IPv6 pool dropdown should be visible with default pool preselected + const v6PoolDropdown = page.getByLabel('IPv6 pool') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') // Open dropdown to verify available options (only v6 pools should be available) - await poolDropdown.click() + await v6PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() await expect(page.getByRole('option', { name: 'ip-pool-4' })).toBeVisible() - // IPv4 pools should not be available in this silo - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() - await expect(page.getByRole('option', { name: 'ip-pool-3' })).toBeHidden() }) test('floating IP create form shows IPv6 default pool preselected', async ({ @@ -255,26 +271,122 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - // Verify ephemeral IP checkbox is not checked by default - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify ephemeral IP checkboxes are not checked by default (no defaults in pelerines silo) + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', }) - await expect(ephemeralCheckbox).not.toBeChecked() - // Pool dropdown should not be shown unless ephemeral IP is enabled. - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeHidden() + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).not.toBeChecked() + + // Pool dropdowns should not be shown unless ephemeral IPs are enabled + const v4PoolDropdown = page.getByLabel('IPv4 pool') + await expect(v4PoolDropdown).toBeHidden() - // Enabling ephemeral IP should allow selecting from available pools. - await ephemeralCheckbox.click() - await expect(ephemeralCheckbox).toBeChecked() - await expect(poolDropdown).toBeVisible() + // Enabling IPv4 ephemeral IP should show pool dropdown + await v4Checkbox.click() + await expect(v4Checkbox).toBeChecked() + await expect(v4PoolDropdown).toBeVisible() // Open dropdown to verify available options - await poolDropdown.click() + await v4PoolDropdown.click() // Both pools are linked to this silo but neither is default await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + }) + + test('submitting with ephemeral IP checked but no pool selected is blocked', async ({ + browser, + }) => { + const page = await getPageAsUser(browser, 'Theodor Adorno') + await page.goto('/projects/adorno-project/instances-new') + + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-pool-test') + + // Select a silo image for boot disk + await page.getByRole('tab', { name: 'Silo images' }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() + await page.getByRole('option', { name: 'ubuntu-22-04' }).click() + + // Check the v4 ephemeral IP checkbox — no default pool will be pre-selected + // because pelerines has no default pools + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', + }) + await v4Checkbox.click() + await expect(v4Checkbox).toBeChecked() + + // Verify no pool is selected + const v4PoolDropdown = page.getByLabel('IPv4 pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('Select a pool') + + // Try to submit — pool is required when checkbox is checked, so form + // validation should prevent the request from being sent + await page.getByRole('button', { name: 'Create instance' }).click() + + // RHF required validation should show an error on the pool field + await expect( + page.getByTestId('scroll-container').getByText('IPv4 pool is required') + ).toBeVisible() + + // Should still be on the create page + await expect(page).toHaveURL('/projects/adorno-project/instances-new') + + // Now select a pool and verify the form submits successfully + await v4PoolDropdown.click() + await page.getByRole('option', { name: 'ip-pool-1' }).click() + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/no-pool-test/) + }) + + test('submitting with IPv6 ephemeral IP checked but no pool selected is blocked', async ({ + browser, + }) => { + const page = await getPageAsUser(browser, 'Theodor Adorno') + await page.goto('/projects/adorno-project/instances-new') + + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-v6-pool-test') + + // Select a silo image for boot disk + await page.getByRole('tab', { name: 'Silo images' }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() + await page.getByRole('option', { name: 'ubuntu-22-04' }).click() + + // Check the v6 ephemeral IP checkbox — no default pool will be pre-selected + // because pelerines has no default pools + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', + }) + await v6Checkbox.click() + await expect(v6Checkbox).toBeChecked() + + // Verify no pool is selected + const v6PoolDropdown = page.getByLabel('IPv6 pool') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('Select a pool') + + // Try to submit — pool is required when checkbox is checked, so form + // validation should prevent the request from being sent + await page.getByRole('button', { name: 'Create instance' }).click() + + // RHF required validation should show an error on the pool field + await expect( + page.getByTestId('scroll-container').getByText('IPv6 pool is required') + ).toBeVisible() + + // Should still be on the create page + await expect(page).toHaveURL('/projects/adorno-project/instances-new') + + // Now select a pool and verify the form submits successfully + await v6PoolDropdown.click() + await page.getByRole('option', { name: 'ip-pool-2' }).click() + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/no-v6-pool-test/) }) test('floating IP create form handles missing default pool gracefully', async ({ @@ -307,19 +419,17 @@ test.describe('IP pool configuration: no-pools silo (no IP pools)', () => { const defaultRadio = page.getByRole('radio', { name: 'Default' }) await expect(defaultRadio).toBeChecked() - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // When there are no pools, both checkboxes should be visible but disabled + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv4 address', }) - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() - - await ephemeralCheckbox.hover() - await expect( - page.getByRole('tooltip').filter({ hasText: /No compatible IP pools available/ }) - ).toBeVisible() - - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeHidden() + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate IPv6 address', + }) + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) const dialog = page.getByRole('dialog')