From 22060ff1d34ee6a4e3500a25ff3fa4dd524fcc8a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 10:55:56 -0800 Subject: [PATCH 01/19] copy Omicron's sort order for mock API items --- mock-api/msw/util.spec.ts | 6 ++- mock-api/msw/util.ts | 78 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index 4704eb7d8b..879083e0b8 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -24,8 +24,10 @@ 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) + const sortedItems = [...items].sort((a, b) => a.id.localeCompare(b.id)) + 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 b8360712a8..951c75962d 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -42,12 +42,68 @@ import { Rando } from './rando' interface PaginateOptions { limit?: number | null pageToken?: string | null + sortBy?: + | 'name_ascending' + | 'name_descending' + | 'id_ascending' + | 'time_and_id_ascending' + | 'time_and_id_descending' } export interface ResultsPage { items: I[] next_page: string | null } +/** + * 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: + | 'name_ascending' + | 'name_descending' + | 'id_ascending' + | 'time_and_id_ascending' + | 'time_and_id_descending' +): I[] { + const sorted = [...items] + + switch (sortBy) { + case 'name_ascending': + return sorted.sort((a, b) => { + const aName = 'name' in a ? String(a.name) : a.id + const bName = 'name' in b ? String(b.name) : b.id + return aName.localeCompare(bName) + }) + case 'name_descending': + return sorted.sort((a, b) => { + const aName = 'name' in a ? String(a.name) : a.id + const bName = 'name' in b ? String(b.name) : b.id + return bName.localeCompare(aName) + }) + case 'id_ascending': + return sorted.sort((a, b) => a.id.localeCompare(b.id)) + case 'time_and_id_ascending': + return sorted.sort((a, b) => { + const aTime = 'time_created' in a ? String(a.time_created) : '' + const bTime = 'time_created' in b ? String(b.time_created) : '' + const timeCompare = aTime.localeCompare(bTime) + return timeCompare !== 0 ? timeCompare : a.id.localeCompare(b.id) + }) + case 'time_and_id_descending': + return sorted.sort((a, b) => { + const aTime = 'time_created' in a ? String(a.time_created) : '' + const bTime = 'time_created' in b ? String(b.time_created) : '' + const timeCompare = bTime.localeCompare(aTime) + return timeCompare !== 0 ? timeCompare : b.id.localeCompare(a.id) + }) + } +} + export const paginated =

( params: P, items: I[] @@ -55,26 +111,36 @@ export const paginated =

( const limit = params.limit || 100 const pageToken = params.pageToken - let startIndex = pageToken ? items.findIndex((i) => i.id === pageToken) : 0 + // 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 ? sortedItems.findIndex((i) => i.id === pageToken) : 0 startIndex = startIndex < 0 ? 0 : startIndex - 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: `${sortedItems[startIndex + limit].id}`, } } From f7b01d83f0da0900580808980600687ee0cd1bd0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 11:03:17 -0800 Subject: [PATCH 02/19] A few tweaks for datetime comparisons --- mock-api/msw/util.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 951c75962d..f680da70cb 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -86,20 +86,28 @@ function sortItems( return bName.localeCompare(aName) }) case 'id_ascending': - return sorted.sort((a, b) => a.id.localeCompare(b.id)) + // Use pure lexicographic comparison for UUIDs to match Rust's derived Ord + // and avoid locale-dependent behavior + return sorted.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) case 'time_and_id_ascending': return sorted.sort((a, b) => { - const aTime = 'time_created' in a ? String(a.time_created) : '' - const bTime = 'time_created' in b ? String(b.time_created) : '' - const timeCompare = aTime.localeCompare(bTime) - return timeCompare !== 0 ? timeCompare : a.id.localeCompare(b.id) + // Compare timestamps numerically to handle Date objects and non-ISO formats + const aTime = + 'time_created' in a ? new Date(a.time_created as string | Date).valueOf() : -Infinity + const bTime = + 'time_created' in b ? new Date(b.time_created as string | Date).valueOf() : -Infinity + const timeCompare = aTime - bTime + return timeCompare !== 0 ? timeCompare : a.id < b.id ? -1 : a.id > b.id ? 1 : 0 }) case 'time_and_id_descending': return sorted.sort((a, b) => { - const aTime = 'time_created' in a ? String(a.time_created) : '' - const bTime = 'time_created' in b ? String(b.time_created) : '' - const timeCompare = bTime.localeCompare(aTime) - return timeCompare !== 0 ? timeCompare : b.id.localeCompare(a.id) + // Compare timestamps numerically to handle Date objects and non-ISO formats + const aTime = + 'time_created' in a ? new Date(a.time_created as string | Date).valueOf() : -Infinity + const bTime = + 'time_created' in b ? new Date(b.time_created as string | Date).valueOf() : -Infinity + const timeCompare = bTime - aTime + return timeCompare !== 0 ? timeCompare : b.id < a.id ? -1 : b.id > a.id ? 1 : 0 }) } } From 9a664ef67c8a94381e8afe80a10ab57b797598c2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 11:10:08 -0800 Subject: [PATCH 03/19] locale-agnostic comparisons; handle invalid dates --- mock-api/msw/util.spec.ts | 3 ++- mock-api/msw/util.ts | 20 ++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index 879083e0b8..45fb867b40 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -25,7 +25,8 @@ describe('paginated', () => { const page = paginated({}, items) expect(page.items.length).toBe(100) // Items are sorted by id lexicographically (matching Omicron's UUID sorting behavior) - const sortedItems = [...items].sort((a, b) => a.id.localeCompare(b.id)) + // 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) }) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index f680da70cb..d7919c7867 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -75,15 +75,17 @@ function sortItems( switch (sortBy) { case 'name_ascending': return sorted.sort((a, b) => { + // Use byte-wise lexicographic comparison to match Rust's String ordering, + // not locale-aware localeCompare() const aName = 'name' in a ? String(a.name) : a.id const bName = 'name' in b ? String(b.name) : b.id - return aName.localeCompare(bName) + return aName < bName ? -1 : aName > bName ? 1 : 0 }) case 'name_descending': return sorted.sort((a, b) => { const aName = 'name' in a ? String(a.name) : a.id const bName = 'name' in b ? String(b.name) : b.id - return bName.localeCompare(aName) + return bName < aName ? -1 : bName > aName ? 1 : 0 }) case 'id_ascending': // Use pure lexicographic comparison for UUIDs to match Rust's derived Ord @@ -92,20 +94,26 @@ function sortItems( case 'time_and_id_ascending': return sorted.sort((a, b) => { // Compare timestamps numerically to handle Date objects and non-ISO formats - const aTime = + // Normalize NaN from invalid dates to -Infinity for deterministic ordering + const aRaw = 'time_created' in a ? new Date(a.time_created as string | Date).valueOf() : -Infinity - const bTime = + const bRaw = 'time_created' in b ? new Date(b.time_created as string | Date).valueOf() : -Infinity + const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity + const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity const timeCompare = aTime - bTime return timeCompare !== 0 ? timeCompare : a.id < b.id ? -1 : a.id > b.id ? 1 : 0 }) case 'time_and_id_descending': return sorted.sort((a, b) => { // Compare timestamps numerically to handle Date objects and non-ISO formats - const aTime = + // Normalize NaN from invalid dates to -Infinity for deterministic ordering + const aRaw = 'time_created' in a ? new Date(a.time_created as string | Date).valueOf() : -Infinity - const bTime = + const bRaw = 'time_created' in b ? new Date(b.time_created as string | Date).valueOf() : -Infinity + const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity + const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity const timeCompare = bTime - aTime return timeCompare !== 0 ? timeCompare : b.id < a.id ? -1 : b.id > a.id ? 1 : 0 }) From 942f8826b8fbf90b9ea3dc4a6183e314f4880a74 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 11:32:19 -0800 Subject: [PATCH 04/19] refactor; handle different page tokens --- mock-api/msw/util.ts | 87 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index d7919c7867..98b5d3e7ae 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -39,16 +39,19 @@ 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?: - | 'name_ascending' - | 'name_descending' - | 'id_ascending' - | 'time_and_id_ascending' - | 'time_and_id_descending' + sortBy?: SortMode } + export interface ResultsPage { items: I[] next_page: string | null @@ -61,15 +64,7 @@ export interface ResultsPage { * 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: - | 'name_ascending' - | 'name_descending' - | 'id_ascending' - | 'time_and_id_ascending' - | 'time_and_id_descending' -): I[] { +function sortItems(items: I[], sortBy: SortMode): I[] { const sorted = [...items] switch (sortBy) { @@ -120,6 +115,57 @@ function sortItems( } } +/** + * 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 ? String(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 + const [time, id] = pageToken.split('|', 2) + return sortedItems.findIndex( + (i) => + i.id === id && + ('time_created' in i ? String(i.time_created) === time : false) + ) + } +} + export const paginated =

( params: P, items: I[] @@ -137,8 +183,13 @@ export const paginated =

( const sortedItems = sortItems(items, sortBy) - let startIndex = pageToken ? sortedItems.findIndex((i) => i.id === pageToken) : 0 - startIndex = startIndex < 0 ? 0 : startIndex + 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 > sortedItems.length) { return { @@ -156,7 +207,7 @@ export const paginated =

( return { items: sortedItems.slice(startIndex, startIndex + limit), - next_page: `${sortedItems[startIndex + limit].id}`, + next_page: getPageToken(sortedItems[startIndex + limit], sortBy), } } From bb63b6d5954a41a4e03f2c738417b59bc43061ac Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 11:33:17 -0800 Subject: [PATCH 05/19] npm run fmt --- mock-api/msw/util.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 98b5d3e7ae..9251169f99 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -91,9 +91,13 @@ function sortItems(items: I[], sortBy: SortMode): I[] // Compare timestamps numerically to handle Date objects and non-ISO formats // Normalize NaN from invalid dates to -Infinity for deterministic ordering const aRaw = - 'time_created' in a ? new Date(a.time_created as string | Date).valueOf() : -Infinity + 'time_created' in a + ? new Date(a.time_created as string | Date).valueOf() + : -Infinity const bRaw = - 'time_created' in b ? new Date(b.time_created as string | Date).valueOf() : -Infinity + 'time_created' in b + ? new Date(b.time_created as string | Date).valueOf() + : -Infinity const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity const timeCompare = aTime - bTime @@ -104,9 +108,13 @@ function sortItems(items: I[], sortBy: SortMode): I[] // Compare timestamps numerically to handle Date objects and non-ISO formats // Normalize NaN from invalid dates to -Infinity for deterministic ordering const aRaw = - 'time_created' in a ? new Date(a.time_created as string | Date).valueOf() : -Infinity + 'time_created' in a + ? new Date(a.time_created as string | Date).valueOf() + : -Infinity const bRaw = - 'time_created' in b ? new Date(b.time_created as string | Date).valueOf() : -Infinity + 'time_created' in b + ? new Date(b.time_created as string | Date).valueOf() + : -Infinity const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity const timeCompare = bTime - aTime @@ -150,7 +158,9 @@ function findStartIndex( 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)) + 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) @@ -160,8 +170,7 @@ function findStartIndex( const [time, id] = pageToken.split('|', 2) return sortedItems.findIndex( (i) => - i.id === id && - ('time_created' in i ? String(i.time_created) === time : false) + i.id === id && ('time_created' in i ? String(i.time_created) === time : false) ) } } From c47a58e4c383d63d2c9be03fd1a091abd2e1c6ba Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 11:47:28 -0800 Subject: [PATCH 06/19] normalizeTime util for tokens --- mock-api/msw/util.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 9251169f99..fa00a225fb 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -57,6 +57,20 @@ export interface ResultsPage { 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. @@ -140,7 +154,7 @@ function getPageToken(item: I, sortBy: SortMode): stri 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 ? String(item.time_created) : '' + const time = 'time_created' in item ? normalizeTime(item.time_created) : '' return `${time}|${item.id}` } } @@ -170,7 +184,8 @@ function findStartIndex( const [time, id] = pageToken.split('|', 2) return sortedItems.findIndex( (i) => - i.id === id && ('time_created' in i ? String(i.time_created) === time : false) + i.id === id && + ('time_created' in i ? normalizeTime(i.time_created) === time : false) ) } } From 5dc74d38a199b29a29dfb82ae3a579d87e2af1b0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 12:07:38 -0800 Subject: [PATCH 07/19] Improvement for missing time data --- mock-api/msw/util.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index fa00a225fb..8333b72fd0 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -181,12 +181,12 @@ function findStartIndex( 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) => - i.id === id && - ('time_created' in i ? normalizeTime(i.time_created) === time : false) - ) + return sortedItems.findIndex((i) => { + const itemTime = 'time_created' in i ? normalizeTime(i.time_created) : '' + return i.id === id && itemTime === time + }) } } From f0d0c23d40356dad301ac1596b7f38a20ce9c578 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 20:31:33 -0800 Subject: [PATCH 08/19] Use Remeda's sortBy to clean up ordering --- mock-api/msw/util.ts | 66 +++++++++++++------------------------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 6d0fcc82e7..a37103c1da 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 { @@ -82,61 +83,32 @@ function normalizeTime(t: unknown): string { * https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L511-L512 */ function sortItems(items: I[], sortBy: SortMode): I[] { - const sorted = [...items] + // 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': - return sorted.sort((a, b) => { - // Use byte-wise lexicographic comparison to match Rust's String ordering, - // not locale-aware localeCompare() - const aName = 'name' in a ? String(a.name) : a.id - const bName = 'name' in b ? String(b.name) : b.id - return aName < bName ? -1 : aName > bName ? 1 : 0 - }) + // Use byte-wise lexicographic comparison to match Rust's String ordering + return R.sortBy(items, (item) => ('name' in item ? String(item.name) : item.id)) case 'name_descending': - return sorted.sort((a, b) => { - const aName = 'name' in a ? String(a.name) : a.id - const bName = 'name' in b ? String(b.name) : b.id - return bName < aName ? -1 : bName > aName ? 1 : 0 - }) + return R.pipe( + items, + R.sortBy((item) => ('name' in item ? String(item.name) : item.id)), + R.reverse() + ) case 'id_ascending': // Use pure lexicographic comparison for UUIDs to match Rust's derived Ord - // and avoid locale-dependent behavior - return sorted.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + return R.sortBy(items, (item) => item.id) case 'time_and_id_ascending': - return sorted.sort((a, b) => { - // Compare timestamps numerically to handle Date objects and non-ISO formats - // Normalize NaN from invalid dates to -Infinity for deterministic ordering - const aRaw = - 'time_created' in a - ? new Date(a.time_created as string | Date).valueOf() - : -Infinity - const bRaw = - 'time_created' in b - ? new Date(b.time_created as string | Date).valueOf() - : -Infinity - const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity - const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity - const timeCompare = aTime - bTime - return timeCompare !== 0 ? timeCompare : a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - }) + // 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 sorted.sort((a, b) => { - // Compare timestamps numerically to handle Date objects and non-ISO formats - // Normalize NaN from invalid dates to -Infinity for deterministic ordering - const aRaw = - 'time_created' in a - ? new Date(a.time_created as string | Date).valueOf() - : -Infinity - const bRaw = - 'time_created' in b - ? new Date(b.time_created as string | Date).valueOf() - : -Infinity - const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity - const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity - const timeCompare = bTime - aTime - return timeCompare !== 0 ? timeCompare : b.id < a.id ? -1 : b.id > a.id ? 1 : 0 - }) + return R.pipe(items, R.sortBy(timeValue, (item) => item.id), R.reverse()) } } From c181dd3f1ebf401b4db9ecb550ff03ff405ac42a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 09:47:23 -0800 Subject: [PATCH 09/19] npm run fmt --- mock-api/msw/util.ts | 10 ++++++++-- test/e2e/disks.e2e.ts | 6 +++--- test/e2e/instance-create.e2e.ts | 33 +++++++++++++++++++-------------- test/e2e/instance-disks.e2e.ts | 6 ++++-- test/e2e/pagination.e2e.ts | 25 +++++++++++++++---------- test/e2e/snapshots.e2e.ts | 27 +++++++++++++++------------ 6 files changed, 64 insertions(+), 43 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index a37103c1da..11bf920130 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -86,7 +86,9 @@ 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 + 'time_created' in item + ? new Date(item.time_created as string | Date).valueOf() + : -Infinity return Number.isFinite(raw) ? raw : -Infinity } @@ -108,7 +110,11 @@ function sortItems(items: I[], sortBy: SortMode): I[] // 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()) + return R.pipe( + items, + R.sortBy(timeValue, (item) => item.id), + R.reverse() + ) } } diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index cb5ac57277..d7d517e6a6 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 - test('from max-size snapshot', async ({ page }) => { + // Using disk-1-snapshot-11 since it's on page 1 after sorting and doesn't have ambiguous matches + test('from snapshot on page 1', 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: 'disk-1-snapshot-11', exact: true }).click() }) test('from image', async ({ page }) => { diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index a839759be3..797a9ad5d4 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -360,10 +360,12 @@ 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`) - await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB']) + // arch-2022-06-01 has size 3 GiB + await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=3 GiB']) }) test('start with an existing disk, but then switch to a silo image', async ({ page }) => { @@ -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']) + // arch-2022-06-01 has size 3 GiB + await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=3 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 ec45ba65d2..4c902836f3 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 disk-1-snapshot-11 since it's on page 1 after sorting and doesn't have ambiguous matches + await page.getByRole('option', { name: 'disk-1-snapshot-11', exact: true }).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' }) + // disk-1-snapshot-11 has size 4 KiB (index 3, so 1024 * 4) + await expectRowVisible(otherDisksTable, { Disk: 'created-disk', size: '4 KiB' }) }) test('Detach disk', async ({ page }) => { diff --git a/test/e2e/pagination.e2e.ts b/test/e2e/pagination.e2e.ts index a0eb9179f8..3b0e16000f 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 c6ddc8183d..aece2a7df0 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,8 @@ 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' }) + // Use disk-1-snapshot-100 which is on page 1 after sorting + 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 +98,12 @@ 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') + // Use disk-1-snapshot-100 which is on page 1 after sorting + 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 +114,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 +122,8 @@ 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') + // Use disk-1-snapshot-100 which is on page 1 after sorting + await clickRowAction(page, 'disk-1-snapshot-100', 'Create image') await expectVisible(page, ['role=dialog[name="Create image from snapshot"]']) From 5b5199ec2f403e0f2dd48772882229beee9ec165 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 11:09:56 -0800 Subject: [PATCH 10/19] Update tests to use first item in list --- test/e2e/disks.e2e.ts | 4 ++-- test/e2e/instance-create.e2e.ts | 8 ++++---- test/e2e/instance-disks.e2e.ts | 8 ++++---- test/e2e/instance-networking.e2e.ts | 14 ++++++-------- test/e2e/instance-serial.e2e.ts | 2 +- test/e2e/inventory.e2e.ts | 5 +++-- test/e2e/ip-pool-silo-config.e2e.ts | 8 ++++---- 7 files changed, 24 insertions(+), 25 deletions(-) diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index d7d517e6a6..c014929c08 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() }) - // Using disk-1-snapshot-11 since it's on page 1 after sorting and doesn't have ambiguous matches + // Using delete-500 - it's first alphabetically and always loads in dropdown test('from snapshot on page 1', async ({ page }) => { await page.getByRole('radio', { name: 'Snapshot' }).click() await page.getByRole('button', { name: 'Source snapshot' }).click() - await page.getByRole('option', { name: 'disk-1-snapshot-11', exact: true }).click() + await page.getByRole('option', { name: 'delete-500' }).click() }) test('from image', async ({ page }) => { diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 797a9ad5d4..40496c7e28 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -364,8 +364,8 @@ test('create instance with a silo image', async ({ page }) => { 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`) - // arch-2022-06-01 has size 3 GiB - await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=3 GiB']) + // Boot disk size defaults to 10 GiB + await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB']) }) test('start with an existing disk, but then switch to a silo image', async ({ page }) => { @@ -377,8 +377,8 @@ test('start with an existing disk, but then switch to a silo image', async ({ pa 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`) - // arch-2022-06-01 has size 3 GiB - await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=3 GiB']) + // Boot disk size defaults to 10 GiB + await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB']) await expectNotVisible(page, ['text=disk-7']) }) diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index 4c902836f3..ad78e1f0e7 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -129,14 +129,14 @@ test('Create disk', async ({ page }) => { await page.getByRole('radio', { name: 'Snapshot' }).click() await page.getByRole('button', { name: 'Source snapshot' }).click() - // Use disk-1-snapshot-11 since it's on page 1 after sorting and doesn't have ambiguous matches - await page.getByRole('option', { name: 'disk-1-snapshot-11', exact: true }).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' }) - // disk-1-snapshot-11 has size 4 KiB (index 3, so 1024 * 4) - await expectRowVisible(otherDisksTable, { Disk: 'created-disk', size: '4 KiB' }) + // 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 6207af8487..2a7b83f67b 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -192,19 +192,17 @@ 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 + // Select the first available floating IP (after sorting: 'cola-float') 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 +327,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 +382,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 +437,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 +486,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 c5082e9a15..aa7871d7f8 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 b77f9e65d3..0816663af5 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 c7df2a5fe3..6e3abc0019 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() From c7613218eb5fe80fbea303191c3e61fafdbc87af Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 12:28:56 -0800 Subject: [PATCH 11/19] Revert to edge case for snapshot-max-size --- app/forms/disk-create.tsx | 9 +++++++-- test/e2e/disks.e2e.ts | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index c63a76e388..2303416e38 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/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index c014929c08..914f977e32 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() }) - // Using delete-500 - it's first alphabetically and always loads in dropdown - test('from snapshot on page 1', async ({ page }) => { + // 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: 'delete-500' }).click() + await page.getByRole('option', { name: 'snapshot-max-size' }).click() }) test('from image', async ({ page }) => { From 24d2a0dc9e1c1b33df0778dce0baa6a7c40a071b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 12:41:57 -0800 Subject: [PATCH 12/19] Remove unnecessary comments --- test/e2e/instance-networking.e2e.ts | 1 - test/e2e/snapshots.e2e.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 2a7b83f67b..239d021a95 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -192,7 +192,6 @@ test('Instance networking tab — floating IPs', async ({ page }) => { await attachFloatingIpButton.click() await expectVisible(page, ['role=heading[name="Attach floating IP"]']) - // Select the first available floating IP (after sorting: 'cola-float') 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() diff --git a/test/e2e/snapshots.e2e.ts b/test/e2e/snapshots.e2e.ts index aece2a7df0..adfa64ea47 100644 --- a/test/e2e/snapshots.e2e.ts +++ b/test/e2e/snapshots.e2e.ts @@ -41,7 +41,6 @@ test('Disk button opens detail modal', async ({ page }) => { test('Confirm delete snapshot', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') - // Use disk-1-snapshot-100 which is on page 1 after sorting const row = page.getByRole('row', { name: 'disk-1-snapshot-100' }) // scroll so the dropdown menu isn't behind the pagination bar @@ -98,7 +97,6 @@ test('Error on delete snapshot', async ({ page }) => { test('Create image from snapshot', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') - // Use disk-1-snapshot-100 which is on page 1 after sorting await clickRowAction(page, 'disk-1-snapshot-100', 'Create image') await expectVisible(page, ['role=dialog[name="Create image from snapshot"]']) @@ -122,7 +120,6 @@ test('Create image from snapshot', async ({ page }) => { test('Create image from snapshot, name taken', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') - // Use disk-1-snapshot-100 which is on page 1 after sorting await clickRowAction(page, 'disk-1-snapshot-100', 'Create image') await expectVisible(page, ['role=dialog[name="Create image from snapshot"]']) From 6e92ed25e2bc16a1fb0f9a73884d5d1cccd5cd86 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 13:07:37 -0800 Subject: [PATCH 13/19] tiebreaker --- mock-api/msw/util.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 11bf920130..f179a483ef 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -95,11 +95,19 @@ function sortItems(items: I[], sortBy: SortMode): I[] switch (sortBy) { case 'name_ascending': // Use byte-wise lexicographic comparison to match Rust's String ordering - return R.sortBy(items, (item) => ('name' in item ? String(item.name) : item.id)) + // 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)), + R.sortBy( + (item) => ('name' in item ? String(item.name) : item.id), + (item) => item.id + ), R.reverse() ) case 'id_ascending': From f64e35b6ca38569e26398e3081286512a4d9d4db Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 18 Feb 2026 12:04:33 -0800 Subject: [PATCH 14/19] A few util updates --- mock-api/msw/util.ts | 45 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index f179a483ef..0862a39713 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -70,7 +70,9 @@ function normalizeTime(t: unknown): string { return t.toISOString() } if (typeof t === 'string') { - return t + // 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 '' } @@ -102,13 +104,11 @@ function sortItems(items: I[], sortBy: SortMode): I[] (item) => item.id ) case 'name_descending': - return R.pipe( + // name descending, id ascending — keeps scan direction stable for the tiebreaker + return R.sortBy( items, - R.sortBy( - (item) => ('name' in item ? String(item.name) : item.id), - (item) => item.id - ), - R.reverse() + [(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 @@ -118,11 +118,8 @@ function sortItems(items: I[], sortBy: SortMode): I[] // 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() - ) + // time descending, id ascending — keeps scan direction stable for the tiebreaker + return R.sortBy(items, [timeValue, 'desc'], (item) => item.id) } } @@ -134,8 +131,8 @@ function getPageToken(item: I, sortBy: SortMode): stri 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 + // 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 @@ -159,11 +156,13 @@ function findStartIndex( ): 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 '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) @@ -183,7 +182,7 @@ export const paginated =

( params: P, items: I[] ) => { - const limit = params.limit || 100 + const limit = params.limit ?? 100 const pageToken = params.pageToken // Apply default sorting based on what fields are available, matching Omicron's defaults: @@ -196,12 +195,12 @@ export const paginated =

( const sortedItems = sortItems(items, sortBy) - let startIndex = pageToken ? findStartIndex(sortedItems, pageToken, sortBy) : 0 + const 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 + // 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 } } if (startIndex > sortedItems.length) { From 070e518ee4529b05558a1924a39e2b7c41a5ec26 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 18 Feb 2026 12:49:49 -0800 Subject: [PATCH 15/19] Test refactors --- mock-api/msw/db.ts | 7 ++++--- mock-api/msw/handlers.ts | 11 ++++++----- test/e2e/instance-create.e2e.ts | 25 +++---------------------- test/e2e/instance-networking.e2e.ts | 8 +------- test/e2e/ip-pool-silo-config.e2e.ts | 29 ++++++++--------------------- test/e2e/utils.ts | 21 +++++++++++++++++++++ 6 files changed, 43 insertions(+), 58 deletions(-) diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 398868e27e..6966651adb 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 042e85d9d0..0dcddd9c91 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/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 80ca06f4c8..ab795fd3b4 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,7 +334,6 @@ 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) - // 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`) @@ -363,7 +346,6 @@ 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') - // 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`) @@ -660,7 +642,6 @@ 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') - // Use arch-2022-06-01 - first silo image after sorting await selectASiloImage(page, 'arch-2022-06-01') await page.getByRole('tab', { name: 'Custom' }).click() diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index cf5e3a5df4..d0d250ee51 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') diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 97129f5218..9063691b13 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: 'arch-2022-06-01' }).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/) @@ -205,9 +198,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: 'arch-2022-06-01' }).click() + await selectASiloImage(page, 'arch-2022-06-01') // Verify ephemeral IP checkbox is checked by default const ephemeralCheckbox = page.getByRole('checkbox', { @@ -251,9 +242,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: 'arch-2022-06-01' }).click() + await selectASiloImage(page, 'arch-2022-06-01') // Verify ephemeral IP checkbox is not checked by default const ephemeralCheckbox = page.getByRole('checkbox', { @@ -300,9 +289,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: 'arch-2022-06-01' }).click() + await selectASiloImage(page, 'arch-2022-06-01') const defaultRadio = page.getByRole('radio', { name: 'Default' }) await expect(defaultRadio).toBeChecked() diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 5d7383e4a9..a463e0e5cd 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -199,6 +199,27 @@ export async function clickRowAction(page: Page, rowName: string, actionName: st await page.getByRole('menuitem', { name: actionName }).click() } +/** + * Select a silo image + */ +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 From 0ce8b3ce1a049512693c6bb4db57aa319a787cf2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 18 Feb 2026 13:59:42 -0800 Subject: [PATCH 16/19] PR review fixes --- mock-api/msw/util.spec.ts | 103 ++++++++++++++++++++++++++++ mock-api/msw/util.ts | 6 +- test/e2e/instance-networking.e2e.ts | 12 ++-- test/e2e/ip-pool-silo-config.e2e.ts | 5 +- 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index d24761b8d0..05251e0c15 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -72,6 +72,109 @@ describe('paginated', () => { 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('d') + + 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('g') + + 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('j') + + 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" + expect(p1.next_page).toBe('xerox|b') + + 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" + expect(p1.next_page).toBe(`${t2}|b`) + + 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}|c`) + + 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 0862a39713..2b61eb2376 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -96,7 +96,7 @@ function sortItems(items: I[], sortBy: SortMode): I[] switch (sortBy) { case 'name_ascending': - // Use byte-wise lexicographic comparison to match Rust's String ordering + // ASCII-safe lexicographic comparison (matches Rust for ASCII identifiers) // Include ID as tiebreaker for stable pagination when names are equal return R.sortBy( items, @@ -183,6 +183,8 @@ export const paginated =

( items: I[] ) => { const limit = params.limit ?? 100 + if (limit < 1) return { items: [], next_page: null } + const pageToken = params.pageToken // Apply default sorting based on what fields are available, matching Omicron's defaults: @@ -191,7 +193,7 @@ export const paginated =

( // 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') + (items.some((i) => 'name' in i) ? 'name_ascending' : 'id_ascending') const sortedItems = sortItems(items, sortBy) diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index d0d250ee51..4406c2513d 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -13,6 +13,7 @@ import { expectRowVisible, expectVisible, selectASiloImage, + selectOption, stopInstance, } from './utils' @@ -187,15 +188,12 @@ test('Instance networking tab — floating IPs', async ({ page }) => { await expectVisible(page, ['role=heading[name="Attach floating IP"]']) 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 selectOption(page, dialog.getByLabel('Floating IP'), 'rootbeer-float') 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: 'cola-float' }) + await expectRowVisible(externalIpTable, { name: 'rootbeer-float' }) // Button should still be enabled because there's an IPv6 floating IP available await expect(attachFloatingIpButton).toBeEnabled() @@ -203,9 +201,7 @@ test('Instance networking tab — floating IPs', async ({ page }) => { // Attach the IPv6 floating IP as well 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 selectOption(page, dialog.getByLabel('Floating IP'), 'ipv6-float') await dialog.getByRole('button', { name: 'Attach' }).click() await expect(page.getByRole('dialog')).toBeHidden() await expectRowVisible(externalIpTable, { name: 'ipv6-float' }) diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 9063691b13..85e5801695 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -23,6 +23,7 @@ import { expectRowVisible, getPageAsUser, selectASiloImage, + selectOption, test, } from './utils' @@ -174,9 +175,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await attachFloatingIpButton.click() 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 selectOption(page, dialog.getByLabel('Floating IP'), floatingIpKosman.name) await dialog.getByRole('button', { name: 'Attach' }).click() await expect(dialog).toBeHidden() From a89939a000840c4f3af32b597f04332a0539617b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 18 Feb 2026 14:35:01 -0800 Subject: [PATCH 17/19] Revert to working test --- mock-api/msw/util.spec.ts | 23 ++++++++++++----------- mock-api/msw/util.ts | 11 ++++++++--- test/e2e/instance-networking.e2e.ts | 7 ++++--- test/e2e/ip-pool-silo-config.e2e.ts | 4 ++-- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index 05251e0c15..50f6dd0a47 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -28,7 +28,7 @@ describe('paginated', () => { // 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) + expect(page.next_page).toBe(sortedItems[99].id) }) it('should return page with null `next_page` if items equal page', () => { @@ -62,12 +62,13 @@ 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() @@ -85,15 +86,15 @@ describe('paginated', () => { 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('d') + 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('g') + 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('j') + 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']) @@ -120,8 +121,8 @@ describe('paginated', () => { ] 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" - expect(p1.next_page).toBe('xerox|b') + // 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']) @@ -150,8 +151,8 @@ describe('paginated', () => { ] 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" - expect(p1.next_page).toBe(`${t2}|b`) + // 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']) @@ -169,7 +170,7 @@ describe('paginated', () => { // 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}|c`) + 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']) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 2b61eb2376..c22bf3f808 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -197,14 +197,18 @@ export const paginated =

( const sortedItems = sortItems(items, sortBy) - const startIndex = pageToken ? findStartIndex(sortedItems, pageToken, sortBy) : 0 + // 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 && startIndex < 0) { + 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 > sortedItems.length) { return { items: [], @@ -221,7 +225,8 @@ export const paginated =

( return { items: sortedItems.slice(startIndex, startIndex + limit), - next_page: getPageToken(sortedItems[startIndex + limit], sortBy), + // 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/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 4406c2513d..0947fcec32 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -13,7 +13,6 @@ import { expectRowVisible, expectVisible, selectASiloImage, - selectOption, stopInstance, } from './utils' @@ -188,7 +187,8 @@ test('Instance networking tab — floating IPs', async ({ page }) => { await expectVisible(page, ['role=heading[name="Attach floating IP"]']) const dialog = page.getByRole('dialog') - await selectOption(page, dialog.getByLabel('Floating IP'), 'rootbeer-float') + await dialog.getByLabel('Floating IP').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 @@ -201,7 +201,8 @@ test('Instance networking tab — floating IPs', async ({ page }) => { // Attach the IPv6 floating IP as well await attachFloatingIpButton.click() await expectVisible(page, ['role=heading[name="Attach floating IP"]']) - await selectOption(page, dialog.getByLabel('Floating IP'), 'ipv6-float') + await dialog.getByLabel('Floating IP').click() + 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' }) diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 85e5801695..0f8ab037b8 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -23,7 +23,6 @@ import { expectRowVisible, getPageAsUser, selectASiloImage, - selectOption, test, } from './utils' @@ -175,7 +174,8 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await attachFloatingIpButton.click() const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() - await selectOption(page, dialog.getByLabel('Floating IP'), floatingIpKosman.name) + await dialog.getByLabel('Floating IP').click() + await page.getByRole('option', { name: floatingIpKosman.name }).click() await dialog.getByRole('button', { name: 'Attach' }).click() await expect(dialog).toBeHidden() From e07eac24c440c2a42b233bdee657672589223e63 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 18 Feb 2026 14:42:17 -0800 Subject: [PATCH 18/19] npm run fmt --- mock-api/msw/util.spec.ts | 19 +++++++++++++++---- mock-api/msw/util.ts | 3 +-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index 50f6dd0a47..f4fbcac4dd 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -83,7 +83,9 @@ describe('paginated', () => { 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 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') @@ -124,7 +126,10 @@ describe('paginated', () => { // 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) + 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() }) @@ -154,7 +159,10 @@ describe('paginated', () => { // 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) + 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() }) @@ -172,7 +180,10 @@ describe('paginated', () => { 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) + 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() }) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index c22bf3f808..5200ec4dac 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -192,8 +192,7 @@ export const paginated =

( // - 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') + params.sortBy || (items.some((i) => 'name' in i) ? 'name_ascending' : 'id_ascending') const sortedItems = sortItems(items, sortBy) From 434be21857d64f39c8696a1fbc90aaf1060b00e3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 18 Feb 2026 15:27:17 -0800 Subject: [PATCH 19/19] Remeda is your friend --- mock-api/msw/util.spec.ts | 3 ++- test/e2e/utils.ts | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index f4fbcac4dd..b4cd46b16d 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' @@ -26,7 +27,7 @@ describe('paginated', () => { expect(page.items.length).toBe(100) // 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)) + const sortedItems = R.sortBy([...items], (i) => i.id) expect(page.items).toEqual(sortedItems.slice(0, 100)) expect(page.next_page).toBe(sortedItems[99].id) }) diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index a463e0e5cd..5eb02cdca4 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -199,9 +199,6 @@ export async function clickRowAction(page: Page, rowName: string, actionName: st await page.getByRole('menuitem', { name: actionName }).click() } -/** - * Select a silo image - */ 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()