From c3541d74162589f40c4a21fef180a733d07fd554 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 1 May 2026 19:22:48 -0500 Subject: [PATCH] Use pre-stopped mock instance to skip having to stop in e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several e2e tests stopped db1 purely to bypass a 'must be stopped' precondition (NIC create, disk attach/detach, boot-disk change, transit IPs edit). Adds db-stopped (with its own boot/data disks and NIC) and points 11 such tests at it. Tests that verify the disabled-while-running state (instance-disks 'Disabled actions', instance-networking 'NIC table', anti-affinity 'add and remove instance from group') keep using db1 + stopInstance. Wall time on the 11 affected tests dropped ~50% (34s → 17s); sum of per-test durations dropped ~55% (~117s → ~52s). Fix tests broken by adding db-stopped mock instance - disks.e2e.ts: bump expected disk row count from 14 to 16 (+2 stopped disks) - firewall-rules.e2e.ts: add db-stopped to instance combobox option lists - instance.e2e.ts: scope 'stopped' text locator to db1's row, since db-stopped's row also shows state 'stopped' --- mock-api/disk.ts | 34 ++++++++++- mock-api/instance.ts | 14 +++++ mock-api/msw/db.ts | 2 +- mock-api/network-interface.ts | 22 +++++++- test/e2e/disks.e2e.ts | 2 +- test/e2e/firewall-rules.e2e.ts | 17 +++++- test/e2e/instance-disks.e2e.ts | 72 ++++++++++-------------- test/e2e/instance-networking.e2e.ts | 6 +- test/e2e/instance.e2e.ts | 4 +- test/e2e/network-interface-create.e2e.ts | 21 ++----- test/e2e/z-index.e2e.ts | 8 +-- 11 files changed, 128 insertions(+), 74 deletions(-) diff --git a/mock-api/disk.ts b/mock-api/disk.ts index cee0301572..e496474b43 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -9,7 +9,7 @@ import type { Disk, DiskState } from '@oxide/api' import { GiB } from '~/util/units' -import { instance } from './instance' +import { instance, stoppedInstance } from './instance' import type { Json } from './json-type' import { Rando } from './msw/rando' import { project, project2 } from './project' @@ -83,9 +83,41 @@ export const disk2: Json = { read_only: false, } +export const stoppedBootDisk: Json = { + id: 'f5bc2085-d18e-4698-86ab-69c62a74e541', + name: 'disk-stopped-boot', + description: 'boot disk for db-stopped', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + state: { state: 'attached', instance: stoppedInstance.id }, + device_path: '/abc', + size: 2 * GiB, + block_size: 2048, + disk_type: 'distributed', + read_only: false, +} + +export const stoppedDataDisk: Json = { + id: '8f25d709-a76b-4399-a105-f2cfd8e52604', + name: 'disk-stopped-data', + description: 'data disk for db-stopped', + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + state: { state: 'attached', instance: stoppedInstance.id }, + device_path: '/def', + size: 4 * GiB, + block_size: 2048, + disk_type: 'distributed', + read_only: false, +} + export const disks: Json[] = [ disk1, disk2, + stoppedBootDisk, + stoppedDataDisk, { id: '3b768903-1d0b-4d78-9308-c12d3889bdfb', name: 'disk-3', diff --git a/mock-api/instance.ts b/mock-api/instance.ts index 6233feb324..db71028452 100644 --- a/mock-api/instance.ts +++ b/mock-api/instance.ts @@ -130,6 +130,19 @@ export const instanceDb2: Json = { boot_disk_id: '48f94570-60d8-401c-857f-5bf912d2d3fc', // disk-2: needs to be written out here to reduce circular dependencies } +// Pre-stopped instance used by tests that only stop an instance to bypass a +// "must be stopped" precondition. Lets those tests skip the stop dance. +export const stoppedInstance: Json = { + ...base, + id: '43ad3fc4-cf13-49ae-8171-35dbf0dd30f0', + name: 'db-stopped', + description: 'a stopped instance', + hostname: 'oxide.com', + project_id: project.id, + run_state: 'stopped', + boot_disk_id: 'f5bc2085-d18e-4698-86ab-69c62a74e541', // disk-stopped-boot +} + export const instances: Json[] = [ instance, failedInstance, @@ -139,4 +152,5 @@ export const instances: Json[] = [ failedCooledRestartNever, instanceUpdateError, instanceDb2, + stoppedInstance, ] diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index c6a08f619c..9986205ed2 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -626,7 +626,7 @@ const initDb = { ipPools: [...mock.ipPools], ipPoolSilos: [...mock.ipPoolSilos], ipPoolRanges: [...mock.ipPoolRanges], - networkInterfaces: [mock.networkInterface], + networkInterfaces: [mock.networkInterface, mock.stoppedInstanceNic], physicalDisks: [...mock.physicalDisks], projects: [...projects], racks: [...mock.racks], diff --git a/mock-api/network-interface.ts b/mock-api/network-interface.ts index 3734b51ae1..4847ede3b0 100644 --- a/mock-api/network-interface.ts +++ b/mock-api/network-interface.ts @@ -7,7 +7,7 @@ */ import type { InstanceNetworkInterface } from '@oxide/api' -import { instance } from './instance' +import { instance, stoppedInstance } from './instance' import type { Json } from './json-type' import { vpc, vpcSubnet } from './vpc' @@ -36,3 +36,23 @@ export const networkInterface: Json = { time_modified: new Date().toISOString(), vpc_id: vpc.id, } + +export const stoppedInstanceNic: Json = { + id: '0864924b-17b0-4467-9dd1-f2461bb84b9a', + name: 'my-nic', + description: 'a network interface', + primary: true, + instance_id: stoppedInstance.id, + ip_stack: { + type: 'dual_stack', + value: { + v4: { ip: '172.30.0.11', transit_ips: ['172.30.0.0/22'] }, + v6: { ip: '::2', transit_ips: ['::/64'] }, + }, + }, + mac: '', + subnet_id: vpcSubnet.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + vpc_id: vpc.id, +} diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 190c3f61c8..9e8b0b9d43 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -52,7 +52,7 @@ 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(14) // 13 + header + await expect(table.getByRole('row')).toHaveCount(16) // 15 + header // check one attached and one not attached await expectRowVisible(table, { diff --git a/test/e2e/firewall-rules.e2e.ts b/test/e2e/firewall-rules.e2e.ts index c073940efe..da4111ba38 100644 --- a/test/e2e/firewall-rules.e2e.ts +++ b/test/e2e/firewall-rules.e2e.ts @@ -639,10 +639,17 @@ test('arbitrary values combobox', async ({ page }) => { 'not-there-yet', 'instance-update-error', 'db2', + 'db-stopped', ]) await input.fill('d') - await expectOptions(page, ['db1', 'instance-update-error', 'db2', 'Custom: d']) + await expectOptions(page, [ + 'db1', + 'instance-update-error', + 'db2', + 'db-stopped', + 'Custom: d', + ]) await input.blur() await expect(page.getByRole('option')).toBeHidden() @@ -651,7 +658,13 @@ test('arbitrary values combobox', async ({ page }) => { await input.focus() // same options show up after blur (there was a bug around this) - await expectOptions(page, ['db1', 'instance-update-error', 'db2', 'Custom: d']) + await expectOptions(page, [ + 'db1', + 'instance-update-error', + 'db2', + 'db-stopped', + 'Custom: d', + ]) // make sure typing in ICMP filter input actually updates the underlying value, // triggering a validation error for bad input. without onInputChange binding diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index ec45ba65d2..3ede1fd20d 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -86,10 +86,7 @@ test('Disabled actions', async ({ page }) => { }) test('Attach disk', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1') - - // Have to stop instance to edit disks - await stopInstance(page) + await page.goto('/projects/mock-project/instances/db-stopped') // Attach existing disk form await page.click('role=button[name="Attach existing disk"]') @@ -100,8 +97,8 @@ test('Attach disk', async ({ page }) => { await expectVisible(page, ['role=dialog >> text="Disk name is required"']) await page.getByRole('combobox', { name: 'Disk name' }).click() - // disk-1 is already attached, so should not be visible in the list - await expectNotVisible(page, ['role=option[name="disk-1"]']) + // disk-stopped-boot is already attached, so should not be visible in the list + await expectNotVisible(page, ['role=option[name="disk-stopped-boot"]']) await expectVisible(page, ['role=option[name="disk-3"]', 'role=option[name="disk-4"]']) await page.click('role=option[name="disk-3"]') @@ -110,14 +107,11 @@ test('Attach disk', async ({ page }) => { }) test('Create disk', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1') + await page.goto('/projects/mock-project/instances/db-stopped') const row = page.getByRole('cell', { name: 'created-disk' }) await expect(row).toBeHidden() - // Have to stop instance to edit disks - await stopInstance(page) - // New disk form const createForm = page.getByRole('dialog', { name: 'Create disk' }) await expect(createForm).toBeHidden() @@ -138,17 +132,14 @@ test('Create disk', async ({ page }) => { }) test('Detach disk', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1') + await page.goto('/projects/mock-project/instances/db-stopped') - // Have to stop instance to edit disks - await stopInstance(page) - - const successMsg = page.getByText('Disk disk-2 detached').first() - const row = page.getByRole('row', { name: 'disk-2' }) + const successMsg = page.getByText('Disk disk-stopped-data detached').first() + const row = page.getByRole('row', { name: 'disk-stopped-data' }) await expect(row).toBeVisible() await expect(successMsg).toBeHidden() - await clickRowAction(page, 'disk-2', 'Detach') + await clickRowAction(page, 'disk-stopped-data', 'Detach') await page.getByRole('button', { name: 'Confirm' }).click() await expect(successMsg).toBeVisible() await expect(row).toBeHidden() // disk row goes away @@ -176,9 +167,7 @@ test('Snapshot disk', async ({ page }) => { }) test('Attach disk error clears when modal closes', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1') - - await stopInstance(page) + await page.goto('/projects/mock-project/instances/db-stopped') // Attach disks until we hit the limit const disksToAttach = [ @@ -244,62 +233,59 @@ test('Attach disk error clears when modal closes', async ({ page }) => { }) test('Change boot disk', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1') + await page.goto('/projects/mock-project/instances/db-stopped') - // assert disk-1 is boot disk, disk-2 also there const bootDiskTable = page.getByRole('table', { name: 'Boot disk' }) const otherDisksTable = page.getByRole('table', { name: 'Additional disks' }) const confirm = page.getByRole('button', { name: 'Confirm' }) const noBootDisk = page.getByText('No boot disk set') const noOtherDisks = page.getByText('No other disks') - const disk1 = { Disk: 'disk-1', size: '2 GiB' } - const disk2 = { Disk: 'disk-2', size: '4 GiB' } - - await expectRowVisible(bootDiskTable, disk1) - await expectRowVisible(otherDisksTable, disk2) + const bootDisk = { Disk: 'disk-stopped-boot', size: '2 GiB' } + const dataDisk = { Disk: 'disk-stopped-data', size: '4 GiB' } - await stopInstance(page) + await expectRowVisible(bootDiskTable, bootDisk) + await expectRowVisible(otherDisksTable, dataDisk) - // Set disk-2 as boot disk - await clickRowAction(page, 'disk-2', 'Set as boot disk') + // Set disk-stopped-data as boot disk + await clickRowAction(page, 'disk-stopped-data', 'Set as boot disk') await confirm.click() - await expectRowVisible(bootDiskTable, disk2) - await expectRowVisible(otherDisksTable, disk1) + await expectRowVisible(bootDiskTable, dataDisk) + await expectRowVisible(otherDisksTable, bootDisk) // Unset boot disk await expect(noBootDisk).toBeHidden() - await clickRowAction(page, 'disk-2', 'Unset as boot disk') + await clickRowAction(page, 'disk-stopped-data', 'Unset as boot disk') await confirm.click() await expect(noBootDisk).toBeVisible() - await expectRowVisible(otherDisksTable, disk1) - await expectRowVisible(otherDisksTable, disk2) + await expectRowVisible(otherDisksTable, bootDisk) + await expectRowVisible(otherDisksTable, dataDisk) await expect(page.getByText('Setting a boot disk is recommended')).toBeVisible() // detach disk so there's only one - await clickRowAction(page, 'disk-2', 'Detach') + await clickRowAction(page, 'disk-stopped-data', 'Detach') await page.getByRole('button', { name: 'Confirm' }).click() - await expect(page.getByText('Instance will boot from disk-1')).toBeVisible() + await expect(page.getByText('Instance will boot from disk-stopped-boot')).toBeVisible() - // set disk-1 back as boot disk - await clickRowAction(page, 'disk-1', 'Set as boot disk') + // set disk-stopped-boot back as boot disk + await clickRowAction(page, 'disk-stopped-boot', 'Set as boot disk') await confirm.click() await expect(noBootDisk).toBeHidden() await expect(noOtherDisks).toBeVisible() - // Remove disk-1 altogether, no disks left - await clickRowAction(page, 'disk-1', 'Unset as boot disk') + // Remove disk-stopped-boot altogether, no disks left + await clickRowAction(page, 'disk-stopped-boot', 'Unset as boot disk') await confirm.click() - await expectRowVisible(otherDisksTable, disk1) + await expectRowVisible(otherDisksTable, bootDisk) - await clickRowAction(page, 'disk-1', 'Detach') + await clickRowAction(page, 'disk-stopped-boot', 'Detach') await page.getByRole('button', { name: 'Confirm' }).click() await expect(noBootDisk).toBeVisible() diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 112a4cb03d..8dfa4e1cbc 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -316,10 +316,8 @@ test('Instance networking tab — SNAT IPs', async ({ page }) => { }) test('Edit network interface - Transit IPs', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1/networking') - - // Stop the instance to enable editing - await stopInstance(page) + // use a stopped instance so editing is enabled + await page.goto('/projects/mock-project/instances/db-stopped/networking') await clickRowAction(page, 'my-nic', 'Edit') diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index 2d60ebcdf1..ec18e67412 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -314,7 +314,9 @@ test("polling doesn't close row actions: instances", async ({ page }) => { await closeToast(page) const menu = page.getByRole('menu') - const stopped = page.getByText('stopped') + // scope to db1's row — db-stopped is also in the table with state 'stopped' + const db1Row = page.getByRole('row', { name: 'db1', exact: false }) + const stopped = db1Row.getByText('stopped') await expect(menu).toBeHidden() await expect(stopped).toBeHidden() diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index d8d2c30552..341c43dc5d 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -7,13 +7,11 @@ */ import { test } from '@playwright/test' -import { expect, expectRowVisible, stopInstance } from './utils' +import { expect, expectRowVisible } from './utils' test('can create a NIC with a specified IP address', async ({ page }) => { - // go to an instance's Network Interfaces page - await page.goto('/projects/mock-project/instances/db1/networking') - - await stopInstance(page) + // use a stopped instance so we can edit NICs + await page.goto('/projects/mock-project/instances/db-stopped/networking') // open the add network interface side modal await page.getByRole('button', { name: 'Add network interface' }).click() @@ -40,10 +38,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => { }) test('can create a NIC with a blank IP address', async ({ page }) => { - // go to an instance's Network Interfaces page - await page.goto('/projects/mock-project/instances/db1/networking') - - await stopInstance(page) + await page.goto('/projects/mock-project/instances/db-stopped/networking') // open the add network interface side modal await page.getByRole('button', { name: 'Add network interface' }).click() @@ -83,9 +78,7 @@ test('can create a NIC with a blank IP address', async ({ page }) => { }) test('can create a NIC with IPv6 only', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1/networking') - - await stopInstance(page) + await page.goto('/projects/mock-project/instances/db-stopped/networking') await page.getByRole('button', { name: 'Add network interface' }).click() @@ -108,9 +101,7 @@ test('can create a NIC with IPv6 only', async ({ page }) => { }) test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1/networking') - - await stopInstance(page) + await page.goto('/projects/mock-project/instances/db-stopped/networking') await page.getByRole('button', { name: 'Add network interface' }).click() diff --git a/test/e2e/z-index.e2e.ts b/test/e2e/z-index.e2e.ts index 8c20f937bb..dc2c73ba7f 100644 --- a/test/e2e/z-index.e2e.ts +++ b/test/e2e/z-index.e2e.ts @@ -7,13 +7,11 @@ */ import { expect, test } from '@playwright/test' -import { expectObscured, stopInstance } from './utils' +import { expectObscured } from './utils' test('Dropdown content in SidebarModal shows on screen', async ({ page }) => { - // go to an instance’s Network Interfaces page - await page.goto('/projects/mock-project/instances/db1/networking') - - await stopInstance(page) + // go to a stopped instance's Network Interfaces page + await page.goto('/projects/mock-project/instances/db-stopped/networking') // open the add network interface side modal await page.getByRole('button', { name: 'Add network interface' }).click()