diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index c63a76e38..2303416e3 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -38,6 +38,7 @@ import { FieldLabel } from '~/ui/lib/FieldLabel' import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' import { Slash } from '~/ui/lib/Slash' +import { ALL_ISH } from '~/util/consts' import { toLocaleDateString } from '~/util/date' import { diskSizeNearest10 } from '~/util/math' import { bytesToGiB, GiB } from '~/util/units' @@ -117,7 +118,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 @@ -394,7 +397,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/util.spec.ts b/mock-api/msw/util.spec.ts index d1638c67d..d24761b8d 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -24,8 +24,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 = [...items].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + expect(page.items).toEqual(sortedItems.slice(0, 100)) + expect(page.next_page).toBe(sortedItems[100].id) }) it('should return page with null `next_page` if items equal page', () => { diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index f1fba82df..f179a483e 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,15 +43,142 @@ 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') { + return 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': + // Use byte-wise lexicographic comparison to match Rust's String ordering + // 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': + return R.pipe( + items, + R.sortBy( + (item) => ('name' in item ? String(item.name) : item.id), + (item) => item.id + ), + R.reverse() + ) + 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': + return R.pipe( + items, + R.sortBy(timeValue, (item) => item.id), + R.reverse() + ) + } +} + +/** + * 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': + // ScanByNameOrId uses Name as marker for name-based sorting + return 'name' in item ? String(item.name) : 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 a name - find first item with this name + return sortedItems.findIndex((i) => + 'name' in i ? i.name === pageToken : i.id === pageToken + ) + 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[] @@ -58,26 +186,41 @@ export const paginated =

( const limit = params.limit || 100 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.length > 0 && 'name' in items[0] ? 'name_ascending' : 'id_ascending') + + const sortedItems = sortItems(items, sortBy) + + let startIndex = pageToken ? findStartIndex(sortedItems, pageToken, sortBy) : 0 + + // Warn if page token not found - helps catch bugs in tests + if (pageToken && startIndex < 0) { + console.warn(`Page token "${pageToken}" not found, starting from beginning`) + startIndex = 0 + } - 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), + next_page: getPageToken(sortedItems[startIndex + limit], sortBy), } } diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index cb5ac5727..914f977e3 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -134,11 +134,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 a839759be..40496c7e2 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -360,9 +360,11 @@ 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') + // Use arch-2022-06-01 - first silo image after sorting + 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']) }) @@ -371,10 +373,12 @@ 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') + // Use arch-2022-06-01 - first silo image after sorting + 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']) }) @@ -667,7 +671,8 @@ 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') + // Use arch-2022-06-01 - first silo image after sorting + await selectASiloImage(page, 'arch-2022-06-01') await page.getByRole('tab', { name: 'Custom' }).click() @@ -702,7 +707,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -746,7 +751,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -789,7 +794,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -829,7 +834,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -891,7 +896,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -953,7 +958,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -1011,7 +1016,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -1093,7 +1098,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') // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -1222,7 +1227,7 @@ test('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 6207af848..239d021a9 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -192,19 +192,16 @@ 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 dialog.getByRole('button', { name: 'Attach' }).click() // Confirm the modal is gone and the new row is showing on the page await expect(page.getByRole('dialog')).toBeHidden() - await expectRowVisible(externalIpTable, { name: 'rootbeer-float' }) + await expectRowVisible(externalIpTable, { name: 'cola-float' }) // Button should still be enabled because there's an IPv6 floating IP available await expect(attachFloatingIpButton).toBeEnabled() @@ -329,7 +326,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') // Open networking accordion and select IPv4-only await page.getByRole('button', { name: 'Networking' }).click() @@ -384,7 +381,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') // Open networking accordion and select IPv6-only await page.getByRole('button', { name: 'Networking' }).click() @@ -439,7 +436,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') // Open networking accordion and select IPv4-only await page.getByRole('button', { name: 'Networking' }).click() @@ -488,7 +485,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') // Open networking accordion and select IPv6-only await page.getByRole('button', { name: 'Networking' }).click() 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 c7df2a5fe..6e3abc001 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -28,7 +28,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { // 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 page.getByRole('option', { name: 'arch-2022-06-01' }).click() // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -75,7 +75,7 @@ test.describe('IP pool configuration: thrax silo (v6-only default)', () => { // 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 page.getByRole('option', { name: 'arch-2022-06-01' }).click() // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -124,7 +124,7 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { // 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 page.getByRole('option', { name: 'arch-2022-06-01' }).click() // Open networking accordion await page.getByRole('button', { name: 'Networking' }).click() @@ -176,7 +176,7 @@ test.describe('IP pool configuration: no-pools silo (no IP pools)', () => { 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 page.getByRole('option', { name: 'arch-2022-06-01' }).click() await page.getByRole('button', { name: 'Networking' }).click() 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"]'])