diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 53d42fb8f..49f554fb7 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -47,6 +47,7 @@ import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' import { Slash } from '~/ui/lib/Slash' import { TipIcon } from '~/ui/lib/TipIcon' +import { ALL_ISH } from '~/util/consts' import { toLocaleDateString } from '~/util/date' import { docLinks } from '~/util/links' import { diskSizeNearest10 } from '~/util/math' @@ -127,7 +128,9 @@ export function CreateDiskSideModalForm({ ) const areImagesLoading = projectImages.isPending || siloImages.isPending - const snapshotsQuery = useQuery(q(api.snapshotList, { query: { project } })) + const snapshotsQuery = useQuery( + q(api.snapshotList, { query: { project, limit: ALL_ISH } }) + ) const snapshots = snapshotsQuery.data?.items || [] // validate disk source size @@ -417,7 +420,9 @@ const DiskNameFromId = ({ disk }: { disk: string }) => { const SnapshotSelectField = ({ control }: { control: Control }) => { const { project } = useProjectSelector() - const snapshotsQuery = useQuery(q(api.snapshotList, { query: { project } })) + const snapshotsQuery = useQuery( + q(api.snapshotList, { query: { project, limit: ALL_ISH } }) + ) const snapshots = snapshotsQuery.data?.items || [] const diskSizeField = useController({ control, name: 'size' }).field diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 398868e27..6966651ad 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -68,14 +68,15 @@ export const resolvePoolSelector = ( | { pool: string; type: 'explicit' } | { type: 'auto'; ip_version?: IpVersion | null } | undefined, - poolType?: IpPoolType + poolType?: IpPoolType, + siloId: string = defaultSilo.id ) => { if (poolSelector?.type === 'explicit') { return lookup.ipPool({ pool: poolSelector.pool }) } // For 'auto' type, find the default pool for the specified IP version and pool type - const silo = lookup.silo({ silo: defaultSilo.id }) + const silo = lookup.silo({ silo: siloId }) const links = db.ipPoolSilos.filter((ips) => ips.silo_id === silo.id && ips.is_default) // Filter candidate pools by both IP version and pool type @@ -114,7 +115,7 @@ export const resolvePoolSelector = ( if (!link) { const typeStr = poolType ? ` ${poolType}` : '' const versionStr = poolSelector?.ip_version ? ` ${poolSelector.ip_version}` : '' - throw notFoundErr(`default${typeStr}${versionStr} pool for silo '${defaultSilo.id}'`) + throw notFoundErr(`default${typeStr}${versionStr} pool for silo '${siloId}'`) } return lookupById(db.ipPools, link.ip_pool_id) } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 042e85d9d..0dcddd9c9 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -305,10 +305,10 @@ export const handlers = makeHandlers({ return true // For mock purposes, just use first unicast pool }) }) - pool = poolWithIp || resolvePoolSelector(undefined, 'unicast') + pool = poolWithIp || resolvePoolSelector(undefined, 'unicast', project.silo_id) } else { // type === 'auto' - pool = resolvePoolSelector(addressAllocator.pool_selector, 'unicast') + pool = resolvePoolSelector(addressAllocator.pool_selector, 'unicast', project.silo_id) ip = getIpFromPool(pool) } @@ -553,7 +553,7 @@ export const handlers = makeHandlers({ // which aren't quite as good as checking that there are actually IPs // available, but they are good things to check // Ephemeral IPs must use unicast pools - const pool = resolvePoolSelector(ip.pool_selector, 'unicast') + const pool = resolvePoolSelector(ip.pool_selector, 'unicast', project.silo_id) getIpFromPool(pool) // Validate that external IP version matches NIC's IP stack @@ -694,7 +694,7 @@ export const handlers = makeHandlers({ floatingIp.instance_id = instanceId } else if (ip.type === 'ephemeral') { // Ephemeral IPs must use unicast pools - const pool = resolvePoolSelector(ip.pool_selector, 'unicast') + const pool = resolvePoolSelector(ip.pool_selector, 'unicast', project.silo_id) const firstAvailableAddress = getIpFromPool(pool) db.ephemeralIps.push({ @@ -876,8 +876,9 @@ export const handlers = makeHandlers({ }, instanceEphemeralIpAttach({ path, query: projectParams, body }) { const instance = lookup.instance({ ...path, ...projectParams }) + const instanceProject = lookup.project(projectParams) // Ephemeral IPs must use unicast pools - const pool = resolvePoolSelector(body.pool_selector, 'unicast') + const pool = resolvePoolSelector(body.pool_selector, 'unicast', instanceProject.silo_id) const ip = getIpFromPool(pool) // Validate that external IP version matches primary NIC's IP stack diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index d1638c67d..b4cd46b16 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import * as R from 'remeda' import { describe, expect, it } from 'vitest' import { FLEET_ID } from '@oxide/api' @@ -24,8 +25,11 @@ describe('paginated', () => { const items = Array.from({ length: 200 }).map((_, i) => ({ id: 'i' + i })) const page = paginated({}, items) expect(page.items.length).toBe(100) - expect(page.items).toEqual(items.slice(0, 100)) - expect(page.next_page).toBe('i100') + // Items are sorted by id lexicographically (matching Omicron's UUID sorting behavior) + // Use locale-agnostic comparison to match the implementation + const sortedItems = R.sortBy([...items], (i) => i.id) + expect(page.items).toEqual(sortedItems.slice(0, 100)) + expect(page.next_page).toBe(sortedItems[99].id) }) it('should return page with null `next_page` if items equal page', () => { @@ -59,16 +63,131 @@ describe('paginated', () => { const page = paginated({ limit: 5 }, items) expect(page.items.length).toBe(5) expect(page.items).toEqual(items.slice(0, 5)) - expect(page.next_page).toBe('f') + expect(page.next_page).toBe('e') }) it('should return the second page when given a `page_token`', () => { const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }] - const page = paginated({ pageToken: 'b' }, items) + // token 'a' is exclusive: start after 'a' + const page = paginated({ pageToken: 'a' }, items) expect(page.items.length).toBe(3) expect(page.items).toEqual([{ id: 'b' }, { id: 'c' }, { id: 'd' }]) expect(page.next_page).toBeNull() }) + + it('returns empty page for limit 0', () => { + const items = [{ id: 'a' }, { id: 'b' }] + const page = paginated({ limit: 0 }, items) + expect(page.items).toEqual([]) + expect(page.next_page).toBeNull() + }) + + it('pages through id_ascending with no overlap and no gap', () => { + // Items a..j; token is the first item of the next page (inclusive marker) + const items = Array.from({ length: 10 }, (_, i) => ({ + id: String.fromCharCode(97 + i), + })) + const p1 = paginated({ limit: 3 }, items) + expect(p1.items.map((i) => i.id)).toEqual(['a', 'b', 'c']) + expect(p1.next_page).toBe('c') + + const p2 = paginated({ limit: 3, pageToken: p1.next_page }, items) + expect(p2.items.map((i) => i.id)).toEqual(['d', 'e', 'f']) + expect(p2.next_page).toBe('f') + + const p3 = paginated({ limit: 3, pageToken: p2.next_page }, items) + expect(p3.items.map((i) => i.id)).toEqual(['g', 'h', 'i']) + expect(p3.next_page).toBe('i') + + const p4 = paginated({ limit: 3, pageToken: p3.next_page }, items) + expect(p4.items.map((i) => i.id)).toEqual(['j']) + expect(p4.next_page).toBeNull() + }) + + it('sorts name_descending with id ascending as tiebreaker', () => { + const items = [ + { id: 'z', name: 'beta' }, + { id: 'a', name: 'alpha' }, + { id: 'b', name: 'alpha' }, // same name, id 'a' < 'b' + ] + const page = paginated({ sortBy: 'name_descending' }, items) + // beta descends first, then alpha items sorted ascending by id + expect(page.items.map((i) => i.id)).toEqual(['z', 'a', 'b']) + }) + + it('pages through name_descending with no overlap and no gap', () => { + const items = [ + { id: 'd', name: 'zest' }, + { id: 'c', name: 'yak' }, + { id: 'b', name: 'xerox' }, + { id: 'a', name: 'walrus' }, + ] + const p1 = paginated({ sortBy: 'name_descending', limit: 2 }, items) + expect(p1.items.map((i) => i.name)).toEqual(['zest', 'yak']) + // next_page token format is "name|id" — last item on page, not first of next + expect(p1.next_page).toBe('yak|c') + + const p2 = paginated( + { sortBy: 'name_descending', limit: 2, pageToken: p1.next_page }, + items + ) + expect(p2.items.map((i) => i.name)).toEqual(['xerox', 'walrus']) + expect(p2.next_page).toBeNull() + }) + + it('sorts time_and_id_ascending with id tiebreaker', () => { + const t1 = '2024-01-01T00:00:00.000Z' + const t2 = '2024-02-01T00:00:00.000Z' + const items = [ + { id: 'b', time_created: t2 }, + { id: 'c', time_created: t1 }, // same time as 'a', id 'a' < 'c' + { id: 'a', time_created: t1 }, + ] + const page = paginated({ sortBy: 'time_and_id_ascending' }, items) + expect(page.items.map((i) => i.id)).toEqual(['a', 'c', 'b']) + }) + + it('pages through time_and_id_ascending with no overlap and no gap', () => { + const t1 = '2024-01-01T00:00:00.000Z' + const t2 = '2024-02-01T00:00:00.000Z' + const items = [ + { id: 'b', time_created: t2 }, + { id: 'c', time_created: t1 }, + { id: 'a', time_created: t1 }, + ] + const p1 = paginated({ sortBy: 'time_and_id_ascending', limit: 2 }, items) + expect(p1.items.map((i) => i.id)).toEqual(['a', 'c']) + // next_page token format is "timestamp|id" — last item on page, not first of next + expect(p1.next_page).toBe(`${t1}|c`) + + const p2 = paginated( + { sortBy: 'time_and_id_ascending', limit: 2, pageToken: p1.next_page }, + items + ) + expect(p2.items.map((i) => i.id)).toEqual(['b']) + expect(p2.next_page).toBeNull() + }) + + it('pages through time_and_id_descending with no overlap and no gap', () => { + const t1 = '2024-01-01T00:00:00.000Z' + const t2 = '2024-02-01T00:00:00.000Z' + const items = [ + { id: 'b', time_created: t2 }, + { id: 'c', time_created: t1 }, + { id: 'a', time_created: t1 }, + ] + // Descending by time: t2 first, then t1 items by id ascending + const p1 = paginated({ sortBy: 'time_and_id_descending', limit: 2 }, items) + expect(p1.items.map((i) => i.id)).toEqual(['b', 'a']) + expect(p1.next_page).toBe(`${t1}|a`) + + const p2 = paginated( + { sortBy: 'time_and_id_descending', limit: 2, pageToken: p1.next_page }, + items + ) + expect(p2.items.map((i) => i.id)).toEqual(['c']) + expect(p2.next_page).toBeNull() + }) }) describe('userHasRole', () => { diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 091a5fde4..5200ec4da 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -9,6 +9,7 @@ import { differenceInSeconds, subHours } from 'date-fns' // Works without the .js for dev server and prod build in MSW mode, but // playwright wants the .js. No idea why, let's just add the .js. import { IPv4, IPv6 } from 'ip-num/IPNumber.js' +import * as R from 'remeda' import { match } from 'ts-pattern' import { @@ -42,42 +43,189 @@ import { getMockOxqlInstanceData } from '../oxql-metrics' import { db, lookupById } from './db' import { Rando } from './rando' +type SortMode = + | 'name_ascending' + | 'name_descending' + | 'id_ascending' + | 'time_and_id_ascending' + | 'time_and_id_descending' + interface PaginateOptions { limit?: number | null pageToken?: string | null + sortBy?: SortMode } + export interface ResultsPage { items: I[] next_page: string | null } +/** + * Normalize a timestamp to a canonical string format for use in page tokens. + * Ensures consistency between token generation and parsing. + */ +function normalizeTime(t: unknown): string { + if (t instanceof Date) { + return t.toISOString() + } + if (typeof t === 'string') { + // Canonicalize to ISO so tokens are stable across different string representations + const d = new Date(t) + return Number.isFinite(d.getTime()) ? d.toISOString() : t + } + return '' +} + +/** + * Sort items based on the sort mode. Implements default sorting behavior to + * match Omicron's pagination defaults. + * https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L427-L428 + * https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L334-L335 + * https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L511-L512 + */ +function sortItems(items: I[], sortBy: SortMode): I[] { + // Extract time as number for sorting, with -Infinity fallback for items without time_created + const timeValue = (item: I) => { + const raw = + 'time_created' in item + ? new Date(item.time_created as string | Date).valueOf() + : -Infinity + return Number.isFinite(raw) ? raw : -Infinity + } + + switch (sortBy) { + case 'name_ascending': + // ASCII-safe lexicographic comparison (matches Rust for ASCII identifiers) + // Include ID as tiebreaker for stable pagination when names are equal + return R.sortBy( + items, + (item) => ('name' in item ? String(item.name) : item.id), + (item) => item.id + ) + case 'name_descending': + // name descending, id ascending — keeps scan direction stable for the tiebreaker + return R.sortBy( + items, + [(item) => ('name' in item ? String(item.name) : item.id), 'desc'], + (item) => item.id + ) + case 'id_ascending': + // Use pure lexicographic comparison for UUIDs to match Rust's derived Ord + return R.sortBy(items, (item) => item.id) + case 'time_and_id_ascending': + // Compare timestamps numerically to handle Date objects and non-ISO formats + // Normalize NaN from invalid dates to -Infinity for deterministic ordering + return R.sortBy(items, timeValue, (item) => item.id) + case 'time_and_id_descending': + // time descending, id ascending — keeps scan direction stable for the tiebreaker + return R.sortBy(items, [timeValue, 'desc'], (item) => item.id) + } +} + +/** + * Get the page token value for an item based on the sort mode. + * Matches Omicron's marker types for each scan mode. + */ +function getPageToken(item: I, sortBy: SortMode): string { + switch (sortBy) { + case 'name_ascending': + case 'name_descending': + // Include ID so the token is unambiguous when multiple items share the same name + return `${'name' in item ? String(item.name) : item.id}|${item.id}` + case 'id_ascending': + // ScanById uses Uuid as marker + return item.id + case 'time_and_id_ascending': + case 'time_and_id_descending': + // ScanByTimeAndId uses (DateTime, Uuid) tuple as marker + // Serialize as "timestamp|id" (using | since timestamps contain :) + const time = 'time_created' in item ? normalizeTime(item.time_created) : '' + return `${time}|${item.id}` + } +} + +/** + * Find the start index for pagination based on the page token and sort mode. + * Handles different marker types matching Omicron's pagination behavior. + */ +function findStartIndex( + sortedItems: I[], + pageToken: string, + sortBy: SortMode +): number { + switch (sortBy) { + case 'name_ascending': + case 'name_descending': { + // Page token is "name|id" — match both to handle duplicate names + const [tokenName, tokenId] = pageToken.split('|', 2) + return sortedItems.findIndex( + (i) => ('name' in i ? String(i.name) : i.id) === tokenName && i.id === tokenId + ) + } + case 'id_ascending': + // Page token is an ID + return sortedItems.findIndex((i) => i.id === pageToken) + case 'time_and_id_ascending': + case 'time_and_id_descending': + // Page token is "timestamp|id" - find item with matching timestamp and ID + // Use same fallback as getPageToken for items without time_created + const [time, id] = pageToken.split('|', 2) + return sortedItems.findIndex((i) => { + const itemTime = 'time_created' in i ? normalizeTime(i.time_created) : '' + return i.id === id && itemTime === time + }) + } +} + export const paginated =

( params: P, items: I[] ) => { - const limit = params.limit || 100 + const limit = params.limit ?? 100 + if (limit < 1) return { items: [], next_page: null } + const pageToken = params.pageToken - let startIndex = pageToken ? items.findIndex((i) => i.id === pageToken) : 0 - startIndex = startIndex < 0 ? 0 : startIndex + // Apply default sorting based on what fields are available, matching Omicron's defaults: + // - name_ascending for endpoints that support name/id sorting (most common) + // - id_ascending for endpoints that only support id sorting + // Note: time_and_id_ascending is only used when explicitly specified in sortBy + const sortBy = + params.sortBy || (items.some((i) => 'name' in i) ? 'name_ascending' : 'id_ascending') + + const sortedItems = sortItems(items, sortBy) + + // markerIndex is -1 when there's no token (first page). With exclusive semantics, + // startIndex is one past the marker — so -1 + 1 = 0 for the first page. + const markerIndex = pageToken ? findStartIndex(sortedItems, pageToken, sortBy) : -1 + + if (pageToken && markerIndex < 0) { + // Token not found: return empty rather than silently restarting, which could cause + // infinite loops in tests or mask bugs from stale tokens + return { items: [], next_page: null } + } + + const startIndex = markerIndex + 1 - if (startIndex > items.length) { + if (startIndex > sortedItems.length) { return { items: [], next_page: null, } } - if (limit + startIndex >= items.length) { + if (limit + startIndex >= sortedItems.length) { return { - items: items.slice(startIndex), + items: sortedItems.slice(startIndex), next_page: null, } } return { - items: items.slice(startIndex, startIndex + limit), - next_page: items[startIndex + limit].id, + items: sortedItems.slice(startIndex, startIndex + limit), + // Marker is the last item of the current page, matching Omicron/Dropshot semantics + next_page: getPageToken(sortedItems[startIndex + limit - 1], sortBy), } } diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 4353845b3..25f84a7bf 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -147,11 +147,11 @@ test.describe('Disk create', () => { await page.getByRole('option', { name: 'delete-500' }).click() }) - // max-size snapshot required a fix + // max-size snapshot required a fix to load all snapshots in dropdown test('from max-size snapshot', async ({ page }) => { await page.getByRole('radio', { name: 'Snapshot' }).click() await page.getByRole('button', { name: 'Source snapshot' }).click() - await page.getByRole('option', { name: 'snapshot-max' }).click() + await page.getByRole('option', { name: 'snapshot-max-size' }).click() }) test('from image', async ({ page }) => { diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ebdbcb632..ab795fd3b 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -14,29 +14,13 @@ import { expectRowVisible, expectVisible, fillNumberInput, + selectAProjectImage, + selectASiloImage, + selectAnExistingDisk, selectOption, test, - type Page, } from './utils' -const selectASiloImage = async (page: Page, name: string) => { - await page.getByRole('tab', { name: 'Silo images' }).click() - await page.getByPlaceholder('Select a silo image', { exact: true }).click() - await page.getByRole('option', { name }).click() -} - -const selectAProjectImage = async (page: Page, name: string) => { - await page.getByRole('tab', { name: 'Project images' }).click() - await page.getByPlaceholder('Select a project image', { exact: true }).click() - await page.getByRole('option', { name }).click() -} - -const selectAnExistingDisk = async (page: Page, name: string) => { - await page.getByRole('tab', { name: 'Existing disks' }).click() - await page.getByRole('combobox', { name: 'Disk' }).click() - await page.getByRole('option', { name }).click() -} - test('can create an instance', async ({ page }) => { await page.goto('/projects/mock-project/instances') await page.locator('text="New Instance"').click() @@ -350,9 +334,10 @@ test('create instance with a silo image', async ({ page }) => { const instanceName = 'my-existing-disk-2' await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') await page.getByRole('button', { name: 'Create instance' }).click() await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + // Boot disk size defaults to 10 GiB await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB']) }) @@ -361,10 +346,11 @@ test('start with an existing disk, but then switch to a silo image', async ({ pa await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectAnExistingDisk(page, 'disk-7') - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') await page.getByRole('button', { name: 'Create instance' }).click() await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) - await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=8 GiB']) + // Boot disk size defaults to 10 GiB + await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB']) await expectNotVisible(page, ['text=disk-7']) }) @@ -656,7 +642,7 @@ test('Validate CPU and RAM', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill('db2') - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') await page.getByRole('tab', { name: 'Custom' }).click() @@ -691,7 +677,7 @@ test('create instance with IPv6-only networking', async ({ page }) => { const instanceName = 'ipv6-only-instance' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -734,7 +720,7 @@ test('create instance with IPv4-only networking', async ({ page }) => { const instanceName = 'ipv4-only-instance' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -776,7 +762,7 @@ test('create instance with dual-stack networking shows both IPs', async ({ page const instanceName = 'dual-stack-instance' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -815,7 +801,7 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4' const instanceName = 'custom-ipv4-nic-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -876,7 +862,7 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6' const instanceName = 'custom-ipv6-nic-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -937,7 +923,7 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem const instanceName = 'custom-dual-stack-nic-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -994,7 +980,7 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) const instanceName = 'ephemeral-ip-nic-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -1075,7 +1061,7 @@ test('network interface options disabled when no VPCs exist', async ({ page }) = const instanceName = 'test-no-vpc-instance' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Configure networking @@ -1204,7 +1190,7 @@ test.skip('can create instance with read-only boot disk', async ({ page }) => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) // Select a silo image - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Check the read-only checkbox await page.getByRole('checkbox', { name: 'Make disk read-only' }).check() diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index ec45ba65d..ad78e1f0e 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -129,12 +129,14 @@ test('Create disk', async ({ page }) => { await page.getByRole('radio', { name: 'Snapshot' }).click() await page.getByRole('button', { name: 'Source snapshot' }).click() - await page.getByRole('option', { name: 'snapshot-heavy' }).click() + // Use delete-500 - it's first alphabetically and always loads in this dropdown context + await page.getByRole('option', { name: 'delete-500' }).click() await createForm.getByRole('button', { name: 'Create disk' }).click() const otherDisksTable = page.getByRole('table', { name: 'Additional disks' }) - await expectRowVisible(otherDisksTable, { Disk: 'created-disk', size: '20 GiB' }) + // Disks created from snapshots use default size of 10 GiB + await expectRowVisible(otherDisksTable, { Disk: 'created-disk', size: '10 GiB' }) }) test('Detach disk', async ({ page }) => { diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index ade1a66db..0947fcec3 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -12,16 +12,10 @@ import { clickRowActions, expectRowVisible, expectVisible, + selectASiloImage, stopInstance, - type Page, } from './utils' -const selectASiloImage = async (page: Page, name: string) => { - await page.getByRole('tab', { name: 'Silo images' }).click() - await page.getByPlaceholder('Select a silo image', { exact: true }).click() - await page.getByRole('option', { name }).click() -} - test('Instance networking tab — NIC table', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') @@ -192,14 +186,9 @@ test('Instance networking tab — floating IPs', async ({ page }) => { await attachFloatingIpButton.click() await expectVisible(page, ['role=heading[name="Attach floating IP"]']) - // Select the 'rootbeer-float' option const dialog = page.getByRole('dialog') - // TODO: this "select the option" syntax is awkward; it's working, but I suspect there's a better way await dialog.getByLabel('Floating IP').click() - await page.keyboard.press('ArrowDown') - await page.keyboard.press('Enter') - // await dialog.getByRole('button', { name: 'rootbeer-float' }).click() - // await dialog.getByRole('button', { name: 'rootbeer-float123.4.56.4/A classic.' }).click() + await page.getByRole('option', { name: 'rootbeer-float' }).click() await dialog.getByRole('button', { name: 'Attach' }).click() // Confirm the modal is gone and the new row is showing on the page @@ -213,8 +202,7 @@ test('Instance networking tab — floating IPs', async ({ page }) => { await attachFloatingIpButton.click() await expectVisible(page, ['role=heading[name="Attach floating IP"]']) await dialog.getByLabel('Floating IP').click() - await page.keyboard.press('ArrowDown') - await page.keyboard.press('Enter') + await page.getByRole('option', { name: 'ipv6-float' }).click() await dialog.getByRole('button', { name: 'Attach' }).click() await expect(page.getByRole('dialog')).toBeHidden() await expectRowVisible(externalIpTable, { name: 'ipv6-float' }) @@ -329,7 +317,7 @@ test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') const instanceName = 'ipv4-only-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Select IPv4-only @@ -384,7 +372,7 @@ test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') const instanceName = 'ipv6-only-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Select IPv6-only @@ -439,7 +427,7 @@ test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') const instanceName = 'ipv4-success-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Select IPv4-only @@ -488,7 +476,7 @@ test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') const instanceName = 'ipv6-success-test' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') + await selectASiloImage(page, 'arch-2022-06-01') // Select IPv6-only diff --git a/test/e2e/instance-serial.e2e.ts b/test/e2e/instance-serial.e2e.ts index c5082e9a1..aa7871d7f 100644 --- a/test/e2e/instance-serial.e2e.ts +++ b/test/e2e/instance-serial.e2e.ts @@ -14,7 +14,7 @@ test('serial console can connect while starting', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill('abc') await page.getByPlaceholder('Select a silo image').click() - await page.getByRole('option', { name: 'ubuntu-22-04' }).click() + await page.getByRole('option', { name: 'arch-2022-06-01' }).click() await page.getByRole('button', { name: 'Create instance' }).click() diff --git a/test/e2e/inventory.e2e.ts b/test/e2e/inventory.e2e.ts index b77f9e65d..0816663af 100644 --- a/test/e2e/inventory.e2e.ts +++ b/test/e2e/inventory.e2e.ts @@ -51,11 +51,12 @@ test('Sled inventory page', async ({ page }) => { state: 'decommissioned', }) - // Visit the sled detail page of the first sled + // Visit the sled detail page of the first sled (after sorting by ID, it's the sled with ID 1ec7df9d) await sledsTable.getByRole('link').first().click() await expectVisible(page, ['role=heading[name*="Sled"]']) - await expect(page.getByText('serialBRM02222869')).toBeVisible() + // After sorting by ID, first sled has serial BRM02222870 + await expect(page.getByText('serialBRM02222870')).toBeVisible() const instancesTab = page.getByRole('tab', { name: 'Instances' }) await expect(instancesTab).toBeVisible() diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 6d68085f7..0f8ab037b 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -22,6 +22,7 @@ import { expect, expectRowVisible, getPageAsUser, + selectASiloImage, test, } from './utils' @@ -33,9 +34,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill('test-instance') // 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() + await selectASiloImage(page, 'arch-2022-06-01') // Verify ephemeral IP checkbox is checked by default const ephemeralCheckbox = page.getByRole('checkbox', { @@ -76,9 +75,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) // 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() + await selectASiloImage(page, 'arch-2022-06-01') // Verify ephemeral IP defaults const ephemeralCheckbox = page.getByRole('checkbox', { @@ -110,9 +107,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { // Create instance with default ephemeral IP await page.getByRole('textbox', { name: 'Name', exact: true }).fill('v4-ephemeral-test') - 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() + await selectASiloImage(page, 'arch-2022-06-01') await page.getByRole('button', { name: 'Create instance' }).click() await closeToast(page) await expect(page).toHaveURL(/\/instances\/v4-ephemeral-test/) @@ -166,9 +161,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { // Create instance await page.getByRole('textbox', { name: 'Name', exact: true }).fill('v4-floating-test') - 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() + await selectASiloImage(page, 'arch-2022-06-01') await page.getByRole('button', { name: 'Create instance' }).click() await closeToast(page) await expect(page).toHaveURL(/\/instances\/v4-floating-test/) @@ -182,8 +175,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await dialog.getByLabel('Floating IP').click() - await page.keyboard.press('ArrowDown') - await page.keyboard.press('Enter') + await page.getByRole('option', { name: floatingIpKosman.name }).click() await dialog.getByRole('button', { name: 'Attach' }).click() await expect(dialog).toBeHidden() @@ -205,9 +197,7 @@ test.describe('IP pool configuration: thrax silo (v6-only default)', () => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill('test-instance') // 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() + await selectASiloImage(page, 'arch-2022-06-01') // Verify ephemeral IP checkbox is checked by default const ephemeralCheckbox = page.getByRole('checkbox', { @@ -251,9 +241,7 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill('test-instance') // 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() + await selectASiloImage(page, 'arch-2022-06-01') // Verify ephemeral IP checkbox is not checked by default const ephemeralCheckbox = page.getByRole('checkbox', { @@ -300,9 +288,7 @@ test.describe('IP pool configuration: no-pools silo (no IP pools)', () => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-default-pool') - 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() + await selectASiloImage(page, 'arch-2022-06-01') const defaultRadio = page.getByRole('radio', { name: 'Default' }) await expect(defaultRadio).toBeChecked() diff --git a/test/e2e/pagination.e2e.ts b/test/e2e/pagination.e2e.ts index a0eb9179f..3b0e16000 100644 --- a/test/e2e/pagination.e2e.ts +++ b/test/e2e/pagination.e2e.ts @@ -27,8 +27,9 @@ test('pagination', async ({ page }) => { await expect(spinner).toBeHidden() await expect(prevButton).toBeDisabled() // we're on the first page - await expectCell(page, 'snapshot-1') - await expectCell(page, `disk-1-snapshot-${PAGE_SIZE}`) + // Items are sorted by name, so first page has: delete-500, disk-1-snapshot-10, ..., disk-1-snapshot-143 + await expectCell(page, 'delete-500') + await expectCell(page, 'disk-1-snapshot-143') await expect(rows).toHaveCount(PAGE_SIZE) await scrollTo(page, 100) @@ -42,18 +43,21 @@ test('pagination', async ({ page }) => { await expect(spinner).toBeHidden() await expectScrollTop(page, 0) // scroll resets to top on page change - await expectCell(page, `disk-1-snapshot-${PAGE_SIZE + 1}`) - await expectCell(page, `disk-1-snapshot-${2 * PAGE_SIZE}`) + // Page 2: disk-1-snapshot-144 to disk-1-snapshot-40 + await expectCell(page, 'disk-1-snapshot-144') + await expectCell(page, 'disk-1-snapshot-40') await expect(rows).toHaveCount(PAGE_SIZE) await nextButton.click() - await expectCell(page, `disk-1-snapshot-${2 * PAGE_SIZE + 1}`) - await expectCell(page, `disk-1-snapshot-${3 * PAGE_SIZE}`) + // Page 3: disk-1-snapshot-41 to disk-1-snapshot-89 + await expectCell(page, 'disk-1-snapshot-41') + await expectCell(page, 'disk-1-snapshot-89') await expect(rows).toHaveCount(PAGE_SIZE) await nextButton.click() - await expectCell(page, `disk-1-snapshot-${3 * PAGE_SIZE + 1}`) - await expectCell(page, 'disk-1-snapshot-167') + // Page 4: disk-1-snapshot-9 to snapshot-max-size (17 items) + await expectCell(page, 'disk-1-snapshot-9') + await expectCell(page, 'snapshot-max-size') await expect(rows).toHaveCount(17) await expect(nextButton).toBeDisabled() // no more pages @@ -62,8 +66,9 @@ test('pagination', async ({ page }) => { await prevButton.click() await expect(spinner).toBeHidden({ timeout: 10 }) // no spinner, cached page await expect(rows).toHaveCount(PAGE_SIZE) - await expectCell(page, `disk-1-snapshot-${2 * PAGE_SIZE + 1}`) - await expectCell(page, `disk-1-snapshot-${3 * PAGE_SIZE}`) + // Back to page 3 + await expectCell(page, 'disk-1-snapshot-41') + await expectCell(page, 'disk-1-snapshot-89') await expectScrollTop(page, 0) // scroll resets to top on prev too await nextButton.click() diff --git a/test/e2e/snapshots.e2e.ts b/test/e2e/snapshots.e2e.ts index c6ddc8183..adfa64ea4 100644 --- a/test/e2e/snapshots.e2e.ts +++ b/test/e2e/snapshots.e2e.ts @@ -12,24 +12,24 @@ test('Click through snapshots', async ({ page }) => { await page.click('role=link[name*="Snapshots"]') await expectVisible(page, [ 'role=heading[name*="Snapshots"]', - 'role=cell[name="snapshot-1"]', - 'role=cell[name="snapshot-2"]', 'role=cell[name="delete-500"]', - 'role=cell[name="snapshot-4"]', - 'role=cell[name="snapshot-disk-deleted"]', + 'role=cell[name="disk-1-snapshot-10"]', + 'role=cell[name="disk-1-snapshot-100"]', + 'role=cell[name="disk-1-snapshot-11"]', + 'role=cell[name="disk-1-snapshot-12"]', ]) // test async disk name fetch const table = page.getByRole('table') - await expectRowVisible(table, { name: 'snapshot-1', disk: 'disk-1' }) - await expectRowVisible(table, { name: 'snapshot-disk-deleted', disk: 'Deleted' }) + await expectRowVisible(table, { name: 'disk-1-snapshot-10', disk: 'disk-1' }) + await expectRowVisible(table, { name: 'delete-500', disk: 'disk-1' }) }) test('Disk button opens detail modal', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') const table = page.getByRole('table') - await expectRowVisible(table, { name: 'snapshot-1', disk: 'disk-1' }) + await expectRowVisible(table, { name: 'disk-1-snapshot-10', disk: 'disk-1' }) await page.getByRole('button', { name: 'disk-1' }).first().click() @@ -41,7 +41,7 @@ test('Disk button opens detail modal', async ({ page }) => { test('Confirm delete snapshot', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') - const row = page.getByRole('row', { name: 'disk-1-snapshot-10' }) + const row = page.getByRole('row', { name: 'disk-1-snapshot-100' }) // scroll so the dropdown menu isn't behind the pagination bar await row.scrollIntoViewIfNeeded() @@ -97,11 +97,11 @@ test('Error on delete snapshot', async ({ page }) => { test('Create image from snapshot', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') - await clickRowAction(page, 'disk-1-snapshot-8', 'Create image') + await clickRowAction(page, 'disk-1-snapshot-100', 'Create image') await expectVisible(page, ['role=dialog[name="Create image from snapshot"]']) - await page.fill('role=textbox[name="Name"]', 'image-from-snapshot-8') + await page.fill('role=textbox[name="Name"]', 'image-from-snapshot-100') await page.fill('role=textbox[name="Description"]', 'image description') await page.fill('role=textbox[name="OS"]', 'Ubuntu') await page.fill('role=textbox[name="Version"]', '20.02') @@ -112,7 +112,7 @@ test('Create image from snapshot', async ({ page }) => { await page.click('role=link[name*="Images"]') await expectRowVisible(page.getByRole('table'), { - name: 'image-from-snapshot-8', + name: 'image-from-snapshot-100', description: 'image description', }) }) @@ -120,7 +120,7 @@ test('Create image from snapshot', async ({ page }) => { test('Create image from snapshot, name taken', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') - await clickRowAction(page, 'disk-1-snapshot-8', 'Create image') + await clickRowAction(page, 'disk-1-snapshot-100', 'Create image') await expectVisible(page, ['role=dialog[name="Create image from snapshot"]']) diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 5d7383e4a..5eb02cdca 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -199,6 +199,24 @@ export async function clickRowAction(page: Page, rowName: string, actionName: st await page.getByRole('menuitem', { name: actionName }).click() } +export const selectASiloImage = async (page: Page, name: string) => { + await page.getByRole('tab', { name: 'Silo images' }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() + await page.getByRole('option', { name }).click() +} + +export const selectAProjectImage = async (page: Page, name: string) => { + await page.getByRole('tab', { name: 'Project images' }).click() + await page.getByPlaceholder('Select a project image', { exact: true }).click() + await page.getByRole('option', { name }).click() +} + +export const selectAnExistingDisk = async (page: Page, name: string) => { + await page.getByRole('tab', { name: 'Existing disks' }).click() + await page.getByRole('combobox', { name: 'Disk' }).click() + await page.getByRole('option', { name }).click() +} + /** * Select an option from a dropdown * labelLocator can either be the dropdown's label text or a more elaborate Locator