From 22060ff1d34ee6a4e3500a25ff3fa4dd524fcc8a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Feb 2026 10:55:56 -0800 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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':