From 071f68b9199df86314f44fcf482833c53b8caaaf Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 2 Feb 2026 14:39:43 -0600 Subject: [PATCH 1/6] bump API for read-only disks --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 18 +++++++++++++++--- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 13 +++++++++++-- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index d366af468..32b09aa4c 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -c765b3539203e34f65cd402f139cf604035d5993 +44e65c3b30720e20b3d3be9bab4efbf6cac0ee2c diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 1ab93484a..b8900b652 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1899,6 +1899,8 @@ export type Disk = { /** unique, mutable, user-controlled identifier for each resource */ name: Name projectId: string + /** Whether or not this disk is read-only. */ + readOnly: boolean size: ByteCount /** ID of snapshot from which disk was created, if any */ snapshotId?: string | null @@ -1920,9 +1922,19 @@ export type DiskSource = type: 'blank' } /** Create a disk from a disk snapshot */ - | { snapshotId: string; type: 'snapshot' } + | { + /** If `true`, the disk created from this snapshot will be read-only. */ + readOnly?: boolean + snapshotId: string + type: 'snapshot' + } /** Create a disk from an image */ - | { imageId: string; type: 'image' } + | { + imageId: string + /** If `true`, the disk created from this image will be read-only. */ + readOnly?: boolean + type: 'image' + } /** Create a blank disk that will accept bulk writes or pull blocks from an external source. */ | { blockSize: BlockSize; type: 'importing_blocks' } @@ -7436,7 +7448,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2026013000.0.0' + apiVersion = '2026013100.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 1445011a7..ba75ba584 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -c765b3539203e34f65cd402f139cf604035d5993 +44e65c3b30720e20b3d3be9bab4efbf6cac0ee2c diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 5f2b5e9f0..ddaf38930 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1734,6 +1734,7 @@ export const Disk = z.preprocess( imageId: z.uuid().nullable().optional(), name: Name, projectId: z.uuid(), + readOnly: SafeBoolean, size: ByteCount, snapshotId: z.uuid().nullable().optional(), state: DiskState, @@ -1749,8 +1750,16 @@ export const DiskSource = z.preprocess( processResponseBody, z.union([ z.object({ blockSize: BlockSize, type: z.enum(['blank']) }), - z.object({ snapshotId: z.uuid(), type: z.enum(['snapshot']) }), - z.object({ imageId: z.uuid(), type: z.enum(['image']) }), + z.object({ + readOnly: SafeBoolean.default(false), + snapshotId: z.uuid(), + type: z.enum(['snapshot']), + }), + z.object({ + imageId: z.uuid(), + readOnly: SafeBoolean.default(false), + type: z.enum(['image']), + }), z.object({ blockSize: BlockSize, type: z.enum(['importing_blocks']) }), ]) ) From be6270c58261f593d37a3659f7d06d6a08f68d91 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 2 Feb 2026 14:46:19 -0600 Subject: [PATCH 2/6] read-only disks UI --- app/components/StateBadge.tsx | 6 ++ app/forms/disk-create.tsx | 59 ++++++++++----- .../project/disks/DiskDetailSideModal.tsx | 3 + app/pages/project/disks/DisksPage.tsx | 9 ++- app/pages/project/instances/StorageTab.tsx | 11 +-- mock-api/disk.ts | 27 +++++++ mock-api/msw/handlers.ts | 15 +++- test/e2e/disks.e2e.ts | 71 ++++++++++++++++++- 8 files changed, 173 insertions(+), 28 deletions(-) diff --git a/app/components/StateBadge.tsx b/app/components/StateBadge.tsx index 3d0e664ff..7e20ebc74 100644 --- a/app/components/StateBadge.tsx +++ b/app/components/StateBadge.tsx @@ -90,3 +90,9 @@ export const DiskTypeBadge = (props: { diskType: DiskType; className?: string }) {props.diskType} ) + +export const ReadOnlyBadge = () => ( + + Read only + +) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 187620dc0..efbc8927a 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -23,6 +23,7 @@ import { type Image, } from '@oxide/api' +import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { DiskSizeField } from '~/components/form/fields/DiskSizeField' import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' @@ -245,7 +246,13 @@ const DiskSourceField = ({ // need to include blockSize when switching back to blank. other // source types get their required fields from form inputs setDiskSource( - newType === 'blank' ? blankDiskSource : ({ type: newType } as DiskSource) + newType === 'blank' + ? blankDiskSource + : newType === 'snapshot' + ? ({ type: 'snapshot', readOnly: false } as DiskSource) + : newType === 'image' + ? ({ type: 'image', readOnly: false } as DiskSource) + : ({ type: newType } as DiskSource) ) }} > @@ -271,25 +278,41 @@ const DiskSourceField = ({ /> )} {diskSource.type === 'image' && ( - toImageComboboxItem(i, true))} - required - onChange={(id) => { - const image = images.find((i) => i.id === id)! - const imageSizeGiB = image.size / GiB - if (diskSizeField.value < imageSizeGiB) { - diskSizeField.onChange(diskSizeNearest10(imageSizeGiB)) - } - }} - /> + <> + toImageComboboxItem(i, true))} + required + onChange={(id) => { + const image = images.find((i) => i.id === id)! + const imageSizeGiB = image.size / GiB + if (diskSizeField.value < imageSizeGiB) { + diskSizeField.onChange(diskSizeNearest10(imageSizeGiB)) + } + }} + /> +
+ + Make disk read-only + +
+ )} - {diskSource.type === 'snapshot' && } + {diskSource.type === 'snapshot' && ( + <> + +
+ + Make disk read-only + +
+ + )} ) diff --git a/app/pages/project/disks/DiskDetailSideModal.tsx b/app/pages/project/disks/DiskDetailSideModal.tsx index a1473916a..92670295e 100644 --- a/app/pages/project/disks/DiskDetailSideModal.tsx +++ b/app/pages/project/disks/DiskDetailSideModal.tsx @@ -87,6 +87,9 @@ export function DiskDetailSideModal({ {disk.snapshotId ?? } + + {disk.readOnly ? 'Yes' : 'No'} + {disk.blockSize.toLocaleString()} bytes diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index f782f513f..c0a4ab7a7 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -23,7 +23,7 @@ import { Storage16Icon, Storage24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' -import { DiskStateBadge, DiskTypeBadge } from '~/components/StateBadge' +import { DiskStateBadge, DiskTypeBadge, ReadOnlyBadge } from '~/components/StateBadge' import { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -147,7 +147,12 @@ export default function DisksPage() { useMemo( () => [ colHelper.accessor('name', { - cell: makeLinkCell((name) => pb.disk({ project, disk: name })), + cell: (info) => ( + + {makeLinkCell((name) => pb.disk({ project, disk: name }))(info)} + {info.row.original.readOnly && } + + ), }), // sneaky: rather than looking at particular states, just look at // whether it has an instance field diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index dcec3bbf2..45ac755b9 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -24,7 +24,7 @@ import { import { Storage24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' -import { DiskStateBadge, DiskTypeBadge } from '~/components/StateBadge' +import { DiskStateBadge, DiskTypeBadge, ReadOnlyBadge } from '~/components/StateBadge' import { AttachDiskModalForm } from '~/forms/disk-attach' import { CreateDiskSideModalForm } from '~/forms/disk-create' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' @@ -85,9 +85,12 @@ export default function StorageTab() { colHelper.accessor('name', { header: 'Disk', cell: (info) => ( - setSelectedDisk(info.row.original)}> - {info.getValue()} - + + setSelectedDisk(info.row.original)}> + {info.getValue()} + + {info.row.original.readOnly && } + ), }), colHelper.accessor('diskType', { diff --git a/mock-api/disk.ts b/mock-api/disk.ts index 260729e1b..3d403e238 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -65,6 +65,7 @@ export const disk1: Json = { size: 2 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, } export const disk2: Json = { @@ -79,6 +80,7 @@ export const disk2: Json = { size: 4 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, } export const disks: Json[] = [ @@ -97,6 +99,7 @@ export const disks: Json[] = [ size: 6 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: '5695b16d-e1d6-44b0-a75c-7b4299831540', @@ -110,6 +113,7 @@ export const disks: Json[] = [ size: 64 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: '4d6f4c76-675f-4cda-b609-f3b8b301addb', @@ -124,6 +128,7 @@ export const disks: Json[] = [ size: 128 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: '41481936-5a6b-4dcd-8dec-26c3bdc343bd', @@ -137,6 +142,7 @@ export const disks: Json[] = [ size: 20 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: '704cd392-9f6b-4a2b-8410-1f1e0794db80', @@ -150,6 +156,7 @@ export const disks: Json[] = [ size: 24 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: '305ee9c7-1930-4a8f-86d7-ed9eece9598e', @@ -163,6 +170,7 @@ export const disks: Json[] = [ size: 16 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: 'ccad8d48-df21-4a80-8c16-683ee6bfb290', @@ -176,6 +184,7 @@ export const disks: Json[] = [ size: 32 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: 'a028160f-603c-4562-bb71-d2d76f1ac2a8', @@ -189,6 +198,7 @@ export const disks: Json[] = [ size: 24 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: '3f23c80f-c523-4d86-8292-2ca3f807bb12', @@ -202,6 +212,7 @@ export const disks: Json[] = [ size: 12 * GiB, block_size: 2048, disk_type: 'distributed', + read_only: false, }, { id: 'b8e3de3a-3c97-4f23-a3f3-73e7d3d3b9c1', @@ -215,6 +226,21 @@ export const disks: Json[] = [ size: 12 * GiB, block_size: 2048, disk_type: 'local', + read_only: false, + }, + { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + name: 'read-only-disk', + description: 'A read-only disk created from a snapshot', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + state: { state: 'detached' }, + device_path: '/ro', + size: 10 * GiB, + block_size: 4096, + disk_type: 'distributed', + read_only: true, }, // put a ton of disks in project 2 so we can use it to test comboboxes ...Array.from({ length: 1010 }).map((_, i) => { @@ -231,6 +257,7 @@ export const disks: Json[] = [ size: 12 * GiB, block_size: 2048, disk_type: 'distributed' as const, + read_only: false, } }), ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index a33f95812..bba3b6540 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -201,14 +201,14 @@ export const handlers = makeHandlers({ if (body.name === 'disk-create-500') throw 500 const { name, description, size, disk_backend } = body + const diskSource = disk_backend.type === 'distributed' ? disk_backend.disk_source : null const newDisk: Json = { id: uuid(), project_id: project.id, // importing_blocks disks go to import_ready, all others go to detached // https://github.com/oxidecomputer/omicron/blob/dd74446/nexus/src/app/sagas/disk_create.rs#L805-L850 state: - disk_backend.type === 'distributed' && - disk_backend.disk_source.type === 'importing_blocks' + diskSource?.type === 'importing_blocks' ? { state: 'import_ready' } : { state: 'detached' }, device_path: '/mnt/disk', @@ -217,6 +217,11 @@ export const handlers = makeHandlers({ size, block_size: getBlockSize(disk_backend), disk_type: disk_backend.type, + image_id: diskSource?.type === 'image' ? diskSource.image_id : null, + snapshot_id: diskSource?.type === 'snapshot' ? diskSource.snapshot_id : null, + read_only: + (diskSource?.type === 'snapshot' || diskSource?.type === 'image') && + diskSource.read_only === true, ...getTimestamps(), } db.disks.push(newDisk) @@ -627,6 +632,10 @@ export const handlers = makeHandlers({ device_path: '/mnt/disk', block_size: getBlockSize(disk_backend), disk_type: disk_backend.type, + read_only: + disk_backend.type === 'distributed' && + disk_backend.disk_source.type === 'snapshot' && + disk_backend.disk_source.read_only === true, ...getTimestamps(), } db.disks.push(newDisk) @@ -974,7 +983,6 @@ export const handlers = makeHandlers({ // endpoint is not paginated. or rather, it's fake paginated return { items: [...ephemeralIps, ...snatIps, ...floatingIps] } }, - instanceExternalSubnetList: NotImplemented, instanceNetworkInterfaceList({ query }) { const instance = lookup.instance(query) const nics = db.networkInterfaces.filter((n) => n.instance_id === instance.id) @@ -2202,6 +2210,7 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, + instanceExternalSubnetList: NotImplemented, instanceMulticastGroupJoin: NotImplemented, instanceMulticastGroupLeave: NotImplemented, instanceMulticastGroupList: NotImplemented, diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index fa59fde89..87bd719a6 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -24,13 +24,42 @@ test('Disk detail side modal', async ({ page }) => { await expect(modal.getByText('disk-1')).toBeVisible() await expect(modal.getByText('2 GiB')).toBeVisible() await expect(modal.getByText('2,048 bytes')).toBeVisible() // block size + await expect(modal.getByText('Read only')).toBeVisible() + await expect(modal.getByText('No')).toBeVisible() +}) + +test('Read-only disk shows badge in table', async ({ page }) => { + await page.goto('/projects/mock-project/disks') + + const table = page.getByRole('table') + + // Verify the read-only disk has the badge + const readOnlyRow = table.getByRole('row', { name: /read-only-disk/ }) + await expect(readOnlyRow).toBeVisible() + await expect(readOnlyRow.getByText('Read only', { exact: true })).toBeVisible() + + // Verify a regular disk does not have the badge + const regularRow = table.getByRole('row', { name: /disk-1 db1/ }) + await expect(regularRow).toBeVisible() + await expect(regularRow.getByText('Read only', { exact: true })).toBeHidden() +}) + +test('Read-only disk detail shows read-only status', async ({ page }) => { + await page.goto('/projects/mock-project/disks') + + await page.getByRole('link', { name: 'read-only-disk' }).click() + + const modal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(modal).toBeVisible() + await expect(modal.getByText('Read only')).toBeVisible() + await expect(modal.getByText('Yes')).toBeVisible() }) test('List disks and snapshot', async ({ page }) => { await page.goto('/projects/mock-project/disks') const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(13) // 12 + header + await expect(table.getByRole('row')).toHaveCount(14) // 13 + header // check one attached and one not attached await expectRowVisible(table, { @@ -145,3 +174,43 @@ test.describe('Disk create', () => { }) /* eslint-enable playwright/expect-expect */ }) + +test('Create disk from snapshot with read-only', async ({ page }) => { + await page.goto('/projects/mock-project/disks-new') + await page.getByRole('textbox', { name: 'Name' }).fill('a-new-disk') + 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('checkbox', { name: 'Make disk read-only' }).check() + + await page.getByRole('button', { name: 'Create disk' }).click() + await expect(page.getByRole('dialog', { name: 'Create disk' })).toBeHidden() + + const row = page.getByRole('row', { name: /a-new-disk/ }) + await expect(row.getByText('Read only', { exact: true })).toBeVisible() + + // Verify snapshot ID in detail modal + await page.getByRole('link', { name: 'a-new-disk' }).click() + const modal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(modal.getByText('e6c58826-62fb-4205-820e-620407cd04e7')).toBeVisible() +}) + +test('Create disk from image with read-only', async ({ page }) => { + await page.goto('/projects/mock-project/disks-new') + await page.getByRole('textbox', { name: 'Name' }).fill('a-new-disk') + await page.getByRole('radio', { name: 'Image' }).click() + await page.getByRole('button', { name: 'Source image' }).click() + await page.getByRole('option', { name: 'image-3' }).click() + await page.getByRole('checkbox', { name: 'Make disk read-only' }).check() + + await page.getByRole('button', { name: 'Create disk' }).click() + await expect(page.getByRole('dialog', { name: 'Create disk' })).toBeHidden() + + const row = page.getByRole('row', { name: /a-new-disk/ }) + await expect(row.getByText('Read only', { exact: true })).toBeVisible() + + // Verify image ID in detail modal + await page.getByRole('link', { name: 'a-new-disk' }).click() + const modal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(modal.getByText('4700ecf1-8f48-4ecf-b78e-816ddb76aaca')).toBeVisible() +}) From 8038d9c7ab06f0bea82178592d28316eb7a555d2 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 2 Feb 2026 15:06:08 -0600 Subject: [PATCH 3/6] add read only checkbox to instance create boot disk --- app/forms/disk-create.tsx | 14 ++++----- app/forms/instance-create.tsx | 20 +++++++++++-- mock-api/msw/handlers.ts | 52 +++++++++++++++++++++++---------- test/e2e/instance-create.e2e.ts | 23 +++++++++++++++ 4 files changed, 85 insertions(+), 24 deletions(-) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index efbc8927a..31124b0f7 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { filesize } from 'filesize' import { useMemo } from 'react' import { useController, useForm, type Control } from 'react-hook-form' +import { match } from 'ts-pattern' import { api, @@ -246,13 +247,12 @@ const DiskSourceField = ({ // need to include blockSize when switching back to blank. other // source types get their required fields from form inputs setDiskSource( - newType === 'blank' - ? blankDiskSource - : newType === 'snapshot' - ? ({ type: 'snapshot', readOnly: false } as DiskSource) - : newType === 'image' - ? ({ type: 'image', readOnly: false } as DiskSource) - : ({ type: newType } as DiskSource) + match(newType) + .with('blank', () => blankDiskSource) + .with('snapshot', () => ({ type: 'snapshot', readOnly: false }) as DiskSource) + .with('image', () => ({ type: 'image', readOnly: false }) as DiskSource) + .with('importing_blocks', () => ({ type: 'importing_blocks' }) as DiskSource) + .exhaustive() ) }} > diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 664220c0a..20c134a95 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -112,7 +112,11 @@ const getBootDiskAttachment = ( size: values.bootDiskSize * GiB, diskBackend: { type: 'distributed', - diskSource: { type: 'image', imageId: source }, + diskSource: { + type: 'image', + imageId: source, + readOnly: values.bootDiskReadOnly, + }, }, } } @@ -133,6 +137,7 @@ export type InstanceCreateInput = Assign< siloImageSource: string projectImageSource: string diskSource: string + bootDiskReadOnly: boolean userData: File | null // ssh keys are always specified. we do not need the undefined case @@ -192,6 +197,7 @@ const baseDefaultValues: InstanceCreateInput = { siloImageSource: '', projectImageSource: '', diskSource: '', + bootDiskReadOnly: false, otherDisks: [], networkInterfaces: { type: 'default_ipv4' }, @@ -366,7 +372,7 @@ export default function CreateInstanceForm() { // additional form elements for projectImage and siloImage tabs const bootDiskSizeAndName = ( <> -
+
+
+
+ + Make disk read-only + ) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index bba3b6540..9981392df 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -202,26 +202,45 @@ export const handlers = makeHandlers({ const { name, description, size, disk_backend } = body const diskSource = disk_backend.type === 'distributed' ? disk_backend.disk_source : null + const { image_id, snapshot_id, read_only, state } = match(diskSource) + .with({ type: 'image' }, (s) => ({ + image_id: s.image_id, + snapshot_id: null, + read_only: s.read_only === true, + state: { state: 'detached' } as const, + })) + .with({ type: 'snapshot' }, (s) => ({ + image_id: null, + snapshot_id: s.snapshot_id, + read_only: s.read_only === true, + state: { state: 'detached' } as const, + })) + .with({ type: 'importing_blocks' }, () => ({ + image_id: null, + snapshot_id: null, + read_only: false, + // https://github.com/oxidecomputer/omicron/blob/dd74446/nexus/src/app/sagas/disk_create.rs#L805-L850 + state: { state: 'import_ready' } as const, + })) + .otherwise(() => ({ + image_id: null, + snapshot_id: null, + read_only: false, + state: { state: 'detached' } as const, + })) const newDisk: Json = { id: uuid(), project_id: project.id, - // importing_blocks disks go to import_ready, all others go to detached - // https://github.com/oxidecomputer/omicron/blob/dd74446/nexus/src/app/sagas/disk_create.rs#L805-L850 - state: - diskSource?.type === 'importing_blocks' - ? { state: 'import_ready' } - : { state: 'detached' }, + state, device_path: '/mnt/disk', name, description, size, block_size: getBlockSize(disk_backend), disk_type: disk_backend.type, - image_id: diskSource?.type === 'image' ? diskSource.image_id : null, - snapshot_id: diskSource?.type === 'snapshot' ? diskSource.snapshot_id : null, - read_only: - (diskSource?.type === 'snapshot' || diskSource?.type === 'image') && - diskSource.read_only === true, + image_id, + snapshot_id, + read_only, ...getTimestamps(), } db.disks.push(newDisk) @@ -622,6 +641,12 @@ export const handlers = makeHandlers({ for (const diskParams of allDisks) { if (diskParams.type === 'create') { const { size, name, description, disk_backend } = diskParams + const diskSource = + disk_backend.type === 'distributed' ? disk_backend.disk_source : null + const read_only = match(diskSource) + .with({ type: 'image', read_only: true }, () => true) + .with({ type: 'snapshot', read_only: true }, () => true) + .otherwise(() => false) const newDisk: Json = { id: uuid(), name, @@ -632,10 +657,7 @@ export const handlers = makeHandlers({ device_path: '/mnt/disk', block_size: getBlockSize(disk_backend), disk_type: disk_backend.type, - read_only: - disk_backend.type === 'distributed' && - disk_backend.disk_source.type === 'snapshot' && - disk_backend.disk_source.read_only === true, + read_only, ...getTimestamps(), } db.disks.push(newDisk) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index a8e32240a..6c405f8f7 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -1217,3 +1217,26 @@ test('floating IPs are filtered by NIC IP version', async ({ page }) => { await expect(page.getByText('A network interface is required')).toBeVisible() await expect(page.getByText('to attach a floating IP')).toBeVisible() }) + +test('can create instance with read-only boot disk', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'readonly-boot-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + + // Select a silo image + await selectASiloImage(page, 'ubuntu-22-04') + + // Check the read-only checkbox + await page.getByRole('checkbox', { name: 'Make disk read-only' }).check() + + await page.getByRole('button', { name: 'Create instance' }).click() + await closeToast(page) + + // Wait for navigation to storage tab + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + + // Verify boot disk shows read-only badge + const bootDiskTable = page.getByRole('table', { name: 'Boot disk' }) + await expect(bootDiskTable.getByText('read only')).toBeVisible() +}) From ec130892151e9f6e9e4a8dacf73153e80c5953ae Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 2 Feb 2026 15:20:29 -0600 Subject: [PATCH 4/6] better types in disk create form --- app/forms/disk-create.tsx | 93 ++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 31124b0f7..544326309 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -18,9 +18,7 @@ import { useApiMutation, type BlockSize, type Disk, - type DiskBackend, type DiskCreate, - type DiskSource, type Image, } from '@oxide/api' @@ -43,12 +41,34 @@ import { toLocaleDateString } from '~/util/date' import { diskSizeNearest10 } from '~/util/math' import { bytesToGiB, GiB } from '~/util/units' -const blankDiskSource: DiskSource = { +/** + * Same as DiskSource but with image and snapshot ID optional, reflecting The + * fact that when you select the type, you do not have an image or snapshot + * selected. + */ +type DiskSourceForm = + | { type: 'blank'; blockSize: BlockSize } + | { type: 'importing_blocks'; blockSize: BlockSize } + | { type: 'image'; imageId?: string; readOnly: boolean } + | { type: 'snapshot'; snapshotId?: string; readOnly: boolean } + +type DiskBackendForm = + | { type: 'local' } + | { type: 'distributed'; diskSource: DiskSourceForm } + +type DiskCreateForm = { + name: string + description: string + size: number + diskBackend: DiskBackendForm +} + +const blankDiskSource: DiskSourceForm = { type: 'blank', blockSize: 4096, } -const defaultValues: DiskCreate = { +const defaultValues: DiskCreateForm = { name: '', description: '', size: 10, @@ -126,8 +146,41 @@ export function CreateDiskSideModalForm({ formType="create" resourceName="disk" onDismiss={onDismiss} - onSubmit={({ size, ...rest }) => { - const body = { size: size * GiB, ...rest } + onSubmit={({ size, diskBackend, ...rest }) => { + const body: DiskCreate = { + ...rest, + size: size * GiB, + diskBackend: match(diskBackend) + .with({ type: 'local' }, () => ({ type: 'local' as const })) + .with({ type: 'distributed' }, ({ diskSource }) => ({ + type: 'distributed' as const, + diskSource: match(diskSource) + .with({ type: 'blank' }, (source) => ({ + type: 'blank' as const, + blockSize: source.blockSize, + })) + .with({ type: 'importing_blocks' }, (source) => ({ + type: 'importing_blocks' as const, + blockSize: source.blockSize, + })) + .with({ type: 'image' }, (source) => ({ + type: 'image' as const, + // image ID is validated by the form: it's required when the + // field is present (i.e., when image type is selected) + imageId: source.imageId!, + readOnly: source.readOnly, + })) + .with({ type: 'snapshot' }, (source) => ({ + type: 'snapshot' as const, + // snapshot ID is validated by the form: it's required when + // the field is present (i.e., when snapshot type is selected) + snapshotId: source.snapshotId!, + readOnly: source.readOnly, + })) + .exhaustive(), + })) + .exhaustive(), + } if (onSubmit) { onSubmit(body) } else { @@ -170,13 +223,16 @@ const DiskBackendField = ({ images, areImagesLoading, }: { - control: Control + control: Control images: Image[] areImagesLoading: boolean }) => { const { - field: { value: diskBackend, onChange: setDiskBackend }, + field: { value: diskBackend, onChange }, } = useController({ control, name: 'diskBackend' }) + // react-hook-form types onChange as (...event: any[]) => void + // https://github.com/react-hook-form/react-hook-form/issues/10466 + const setDiskBackend = onChange as (value: DiskBackendForm) => void const diskSizeField = useController({ control, name: 'size' }).field return ( @@ -189,7 +245,7 @@ const DiskBackendField = ({ column defaultChecked={diskBackend.type} onChange={(event) => { - const newType = event.target.value as DiskBackend['type'] + const newType = event.target.value as DiskBackendForm['type'] if (newType === 'local') { setDiskBackend({ type: 'local' }) } else { @@ -226,9 +282,9 @@ const DiskSourceField = ({ images, areImagesLoading, }: { - control: Control - diskSource: DiskSource - setDiskSource: (source: DiskSource) => void + control: Control + diskSource: DiskSourceForm + setDiskSource: (source: DiskSourceForm) => void diskSizeField: { value: number; onChange: (value: number) => void } images: Image[] areImagesLoading: boolean @@ -243,15 +299,18 @@ const DiskSourceField = ({ column defaultChecked={diskSource.type} onChange={(event) => { - const newType = event.target.value as DiskSource['type'] + const newType = event.target.value as DiskSourceForm['type'] // need to include blockSize when switching back to blank. other // source types get their required fields from form inputs setDiskSource( match(newType) .with('blank', () => blankDiskSource) - .with('snapshot', () => ({ type: 'snapshot', readOnly: false }) as DiskSource) - .with('image', () => ({ type: 'image', readOnly: false }) as DiskSource) - .with('importing_blocks', () => ({ type: 'importing_blocks' }) as DiskSource) + .with('snapshot', () => ({ type: 'snapshot' as const, readOnly: false })) + .with('image', () => ({ type: 'image' as const, readOnly: false })) + .with('importing_blocks', () => ({ + type: 'importing_blocks' as const, + blockSize: blankDiskSource.blockSize, + })) .exhaustive() ) }} @@ -327,7 +386,7 @@ const DiskNameFromId = ({ disk }: { disk: string }) => { return <> from {data.name} } -const SnapshotSelectField = ({ control }: { control: Control }) => { +const SnapshotSelectField = ({ control }: { control: Control }) => { const { project } = useProjectSelector() const snapshotsQuery = useQuery(q(api.snapshotList, { query: { project } })) From 433cde264a63b201fe1f1b7b1a9ddf79feba696e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 2 Feb 2026 17:35:21 -0600 Subject: [PATCH 5/6] remove size limit for local disks --- AGENTS.md | 1 + app/components/form/fields/DiskSizeField.tsx | 11 ++--- app/forms/disk-create.tsx | 6 +++ app/forms/instance-create.tsx | 3 ++ mock-api/msw/util.ts | 3 +- test/e2e/disks.e2e.ts | 47 ++++++++++++++++++++ 6 files changed, 65 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b6aed583..5ce1de679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ # Testing code - Run local checks before sending PRs: `npm run lint`, `npm run tsc`, `npm test run`, and `npm run e2ec`; pass `-- --ui` for Playwright UI mode or project/name filters like `npm run e2ec -- instance -g 'boot disk'`. +- You don't usually need to run all the e2e tests, so try to filter by filename. CI will run the full set. - Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked. - Cover role-gated flows by logging in with `getPageAsUser`; exercise negative paths (e.g., forbidden actions) alongside happy paths as shown in `test/e2e/system-update.e2e.ts`. - Consider `expectVisible` and `expectNotVisible` deprecated: prefer `expect().toBeVisible()` and `toBeHidden()` in new code. diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx index cf0a0a021..6c8aa907d 100644 --- a/app/components/form/fields/DiskSizeField.tsx +++ b/app/components/form/fields/DiskSizeField.tsx @@ -12,8 +12,6 @@ import type { ValidateResult, } from 'react-hook-form' -import { MAX_DISK_SIZE_GiB } from '@oxide/api' - import { NumberField } from './NumberField' import type { TextFieldProps } from './TextField' @@ -22,6 +20,8 @@ interface DiskSizeProps< TName extends FieldPath, > extends TextFieldProps { minSize?: number + /** Undefined means no client-side limit (e.g., for local disks) */ + maxSize: number | undefined validate?(diskSizeGiB: number): ValidateResult } @@ -32,6 +32,7 @@ export function DiskSizeField< required = true, name, minSize = 1, + maxSize, validate, ...props }: DiskSizeProps) { @@ -41,7 +42,7 @@ export function DiskSizeField< required={required} name={name} min={minSize} - max={MAX_DISK_SIZE_GiB} + max={maxSize} validate={(diskSizeGiB) => { // Run a number of default validators if (Number.isNaN(diskSizeGiB)) { @@ -50,8 +51,8 @@ export function DiskSizeField< if (diskSizeGiB < minSize) { return `Must be at least ${minSize} GiB` } - if (diskSizeGiB > MAX_DISK_SIZE_GiB) { - return `Can be at most ${MAX_DISK_SIZE_GiB} GiB` + if (maxSize !== undefined && diskSizeGiB > maxSize) { + return `Can be at most ${maxSize} GiB` } // Run any additional validators passed in from the callsite return validate?.(diskSizeGiB) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 544326309..863c73b75 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -13,6 +13,7 @@ import { match } from 'ts-pattern' import { api, + MAX_DISK_SIZE_GiB, q, queryClient, useApiMutation, @@ -203,6 +204,11 @@ export function CreateDiskSideModalForm({ undefined) + .with({ type: 'distributed' }, () => MAX_DISK_SIZE_GiB) + .exhaustive()} validate={(diskSizeGiB: number) => { if (validateSizeGiB && diskSizeGiB < validateSizeGiB) { return `Must be as large as selected ${diskSourceType} (min. ${validateSizeGiB} GiB)` diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 20c134a95..241c91f55 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -24,6 +24,7 @@ import { getCompatiblePools, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, + MAX_DISK_SIZE_GiB, q, queryClient, useApiMutation, @@ -379,6 +380,8 @@ export default function CreateInstanceForm() { name="bootDiskSize" control={control} min={imageSizeGiB || 1} + // Max size applies: this disk can only be distributed + maxSize={MAX_DISK_SIZE_GiB} validate={(diskSizeGiB: number) => { if (imageSizeGiB && diskSizeGiB < imageSizeGiB) { return `Must be as large as selected image (min. ${imageSizeGiB} GiB)` diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index b8360712a..0513465fd 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -163,7 +163,8 @@ export const errIfInvalidDiskSize = (disk: Json) => { if (disk.size < MIN_DISK_SIZE_GiB * GiB) { throw `Disk size must be greater than or equal to ${MIN_DISK_SIZE_GiB} GiB` } - if (disk.size > MAX_DISK_SIZE_GiB * GiB) { + // Local disk size is validated server-side against zpool capacity, not here + if (disk.disk_backend.type === 'distributed' && disk.size > MAX_DISK_SIZE_GiB * GiB) { throw `Disk size must be less than or equal to ${MAX_DISK_SIZE_GiB} GiB` } // Local disks have no source to validate against. Distributed disks from diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 87bd719a6..23eb4b476 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -175,6 +175,53 @@ test.describe('Disk create', () => { /* eslint-enable playwright/expect-expect */ }) +test('Distributed disk clamps size to max of 1023 GiB', async ({ page }) => { + await page.goto('/projects/mock-project/disks-new') + + const sizeInput = page.getByRole('textbox', { name: 'Size (GiB)' }) + await sizeInput.fill('2000') + await sizeInput.blur() + + // Value should be clamped to 1023 + await expect(sizeInput).toHaveValue('1023') +}) + +test('Local disk has no max size limit', async ({ page }) => { + await page.goto('/projects/mock-project/disks-new') + + await page.getByRole('radio', { name: 'Local' }).click() + + const sizeInput = page.getByRole('textbox', { name: 'Size (GiB)' }) + await sizeInput.fill('2000') + await sizeInput.blur() + + // Should not show the max size error + await expect(page.getByText('Can be at most 1023 GiB')).toBeHidden() + + // Value should remain 2000, not clamped to 1023 + await expect(sizeInput).toHaveValue('2000') +}) + +test('Create local disk with size > 1023 GiB', async ({ page }) => { + await page.goto('/projects/mock-project/disks-new') + + await page.getByRole('textbox', { name: 'Name' }).fill('big-local-disk') + await page.getByRole('radio', { name: 'Local' }).click() + + const sizeInput = page.getByRole('textbox', { name: 'Size (GiB)' }) + await sizeInput.fill('2000') + + await page.getByRole('button', { name: 'Create disk' }).click() + + await expect(page.getByRole('dialog', { name: 'Create disk' })).toBeHidden() + await expectToast(page, 'Disk big-local-disk created') + // 2000 GiB is displayed as 1.95 TiB (filesize formatting) + await expectRowVisible(page.getByRole('table'), { + name: 'big-local-disk', + size: '1.95 TiB', + }) +}) + test('Create disk from snapshot with read-only', async ({ page }) => { await page.goto('/projects/mock-project/disks-new') await page.getByRole('textbox', { name: 'Name' }).fill('a-new-disk') From 003421077788085ab69d2a63f2d897ba53adfc69 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 3 Feb 2026 10:54:54 -0600 Subject: [PATCH 6/6] debug-e2e skill + fix flake in disk size cap tests --- .claude/skills/debug-e2e/SKILL.md | 106 ++++++++++++++++++++++++++++++ test/e2e/disks.e2e.ts | 27 +++++--- test/e2e/instance-create.e2e.ts | 22 +------ test/e2e/utils.ts | 22 +++++++ 4 files changed, 150 insertions(+), 27 deletions(-) create mode 100644 .claude/skills/debug-e2e/SKILL.md diff --git a/.claude/skills/debug-e2e/SKILL.md b/.claude/skills/debug-e2e/SKILL.md new file mode 100644 index 000000000..8eafb9cd9 --- /dev/null +++ b/.claude/skills/debug-e2e/SKILL.md @@ -0,0 +1,106 @@ +--- +name: debug-e2e +description: Debug flaky Playwright E2E test failures from CI +--- + +# Debugging Flaky E2E Test Failures + +Use this skill when investigating flaky Playwright E2E test failures from CI. + +## Workflow + +### 1. Get CI failure details + +```bash +# View PR checks status +gh pr checks + +# Get failed test logs +gh run view --log-failed 2>&1 | head -500 + +# Search for specific failure patterns +gh run view --log-failed 2>&1 | rg -C 30 "FAILED|Error:|Expected|Timed out" +``` + +### 2. Reproduce locally with repeat-each + +Run the failing test multiple times to reproduce flaky behavior: + +```bash +# Run specific test 20 times +npm run e2e -- test/e2e/.e2e.ts --repeat-each=20 -g "" + +# Target specific browser if failure is browser-specific +npm run e2e -- test/e2e/.e2e.ts --repeat-each=20 -g "" --project= +``` + +### 2.1 Calibrate test duration + +Start small to get signal quickly, then scale up only if needed. + +- 5-10 repeats on a single browser is usually under a few minutes. +- 20+ repeats across all browsers can take a long time, especially for full files. + +Always run repeats on a single test or at most one file. Never repeat the whole suite. +Prefer narrowing with `-g` and `--project` first, then increase `--repeat-each` once the fix looks stable. + +### 3. Analyze failure artifacts + +When tests fail, Playwright saves traces and error context: + +```bash +# View error context (page snapshot at failure time) +cat test-results/-/error-context.md + +# Open trace viewer (interactive) +npx playwright show-trace test-results/-/trace.zip +``` + +### 4. Common flakiness patterns + +**React Aria NumberInput flakiness**: When `fill()` on a NumberInput doesn't stick, it's often because: + +- The component re-renders after a prop change (e.g., `maxValue` changing) +- Hydration race conditions + +**Fix pattern - NumberInput helper**: + +```typescript +import { fillNumberInput } from './utils' + +await fillNumberInput(input, 'value') +``` + +**Form hydration issues**: Wait for a field that only renders after mount: + +```typescript +await expect(page.getByRole('radiogroup', { name: 'Block size' })).toBeVisible() +``` + +**State change timing**: When clicking changes form state, wait for visual confirmation: + +```typescript +await page.getByRole('radio', { name: 'Local' }).click() +// Wait for dependent UI to update +await expect(page.getByRole('radiogroup', { name: 'Block size' })).toBeHidden() +``` + +### 5. Sleep as last resort + +If deterministic waits don't work, use `sleep()` from `test/e2e/utils.ts`: + +```typescript +import { sleep } from './utils' + +await sleep(200) // Use sparingly, prefer deterministic waits +``` + +### 6. Verify fix is stable + +Run at least 30-50 iterations to confirm flakiness is resolved: + +```bash +npm run e2e -- test/e2e/.e2e.ts --repeat-each=50 -g "" +``` + +A good target is 0 failures out of 100+ runs. Test on all browsers that failed in CI. diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 23eb4b476..adaa2e11a 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -11,6 +11,7 @@ import { expectNoToast, expectRowVisible, expectToast, + fillNumberInput, test, } from './utils' @@ -178,22 +179,26 @@ test.describe('Disk create', () => { test('Distributed disk clamps size to max of 1023 GiB', async ({ page }) => { await page.goto('/projects/mock-project/disks-new') - const sizeInput = page.getByRole('textbox', { name: 'Size (GiB)' }) - await sizeInput.fill('2000') - await sizeInput.blur() + // Wait for form to be hydrated by checking a field that renders after mount + await expect(page.getByRole('radiogroup', { name: 'Block size' })).toBeVisible() - // Value should be clamped to 1023 - await expect(sizeInput).toHaveValue('1023') + const sizeInput = page.getByRole('textbox', { name: 'Size (GiB)' }) + await fillNumberInput(sizeInput, '2000', '1023') }) test('Local disk has no max size limit', async ({ page }) => { await page.goto('/projects/mock-project/disks-new') + // Wait for form to be hydrated by checking a field that renders after mount + await expect(page.getByRole('radiogroup', { name: 'Block size' })).toBeVisible() + await page.getByRole('radio', { name: 'Local' }).click() + // Wait for form to update (Block size disappears in local mode) + await expect(page.getByRole('radiogroup', { name: 'Block size' })).toBeHidden() + const sizeInput = page.getByRole('textbox', { name: 'Size (GiB)' }) - await sizeInput.fill('2000') - await sizeInput.blur() + await fillNumberInput(sizeInput, '2000') // Should not show the max size error await expect(page.getByText('Can be at most 1023 GiB')).toBeHidden() @@ -205,11 +210,17 @@ test('Local disk has no max size limit', async ({ page }) => { test('Create local disk with size > 1023 GiB', async ({ page }) => { await page.goto('/projects/mock-project/disks-new') + // Wait for form to be hydrated by checking a field that renders after mount + await expect(page.getByRole('radiogroup', { name: 'Block size' })).toBeVisible() + await page.getByRole('textbox', { name: 'Name' }).fill('big-local-disk') await page.getByRole('radio', { name: 'Local' }).click() + // Wait for form to update (Block size disappears in local mode) + await expect(page.getByRole('radiogroup', { name: 'Block size' })).toBeHidden() + const sizeInput = page.getByRole('textbox', { name: 'Size (GiB)' }) - await sizeInput.fill('2000') + await fillNumberInput(sizeInput, '2000') await page.getByRole('button', { name: 'Create disk' }).click() diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 6c405f8f7..0cc66742f 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -13,8 +13,8 @@ import { expectNotVisible, expectRowVisible, expectVisible, + fillNumberInput, selectOption, - sleep, test, type Page, } from './utils' @@ -577,25 +577,9 @@ test('create instance with additional disks', async ({ page }) => { // verify that an existing name can't be used await createForm.getByRole('textbox', { name: 'Name', exact: true }).fill('disk-6') - // If we try to fill the size field too soon after render (unnaturally fast -- - // a real user would not be able to do it), the value gets quickly overwritten - // back to the default of 10, possibly because there are renders already in - // flight by the type we fill. This causes test flakes where the field is 10 - // after we've filled 5 and the disk we're creating ends up with 10 GiB in - // the table. The flakes happened in Safari, but by adding a sleep _after_ the - // fill but before the check, we can force the failure in every browser. By - // waiting a bit here _before_ the fill, we give those renders a chance to - // wrap up before we fill. - // - // This is a HACK -- logging in instance create and disk create shows that - // disk create does seem to render again a few hundred ms after the initial - // one, and it appears driven by renders in instance create (specifically the - // usePrefetchedApiQuery calls), but I wasn't able to fix it for real. - await sleep(1000) - const sizeField = createForm.getByRole('textbox', { name: 'Size (GiB)' }) - await sizeField.fill('5') - await expect(sizeField).toHaveValue('5') + // The size field can be overwritten by late renders in the parent form. + await fillNumberInput(sizeField, '5') await createForm.getByRole('button', { name: 'Create disk' }).click() await expect(createForm.getByText('Name is already in use')).toBeVisible() diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 7893e69d2..92df5b60b 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -51,6 +51,28 @@ export async function expectNotVisible(page: Page, selectors: Selector[]) { } } +// React Aria NumberInput can lose recent edits when the form re-renders. Commit +// via blur and poll with short intervals until the value sticks. We consider +// this safe for the e2es because it is working around the fact that Playwright +// interacts with the form much faster than a real user would. +export async function fillNumberInput( + input: Locator, + value: string, + expectedValue: string = value +) { + await expect + .poll( + async () => { + await input.click() + await input.fill(value) + await input.blur() + return input.inputValue() + }, + { intervals: [100, 250, 500] } + ) + .toBe(expectedValue) +} + // Technically this has type AsymmetricMatcher, which is not exported by // Playwright and is (surprisingly) just Record. Rather than use // that, I think it's smarter to do the following in case they ever make the