Umbrel VM Test Authoring
Guidance
- Read other relevant VM tests when you need inspiration.
- Read
packages/umbreld/source/modules/test-utilities/create-test-umbreld.ts to understand the VM test harness API before adding new helpers or guessing how VM lifecycle, auth clients, SSH, and device operations work.
- Use the
umbrel-home machine by default because it is the simplest, unless the behavior specifically needs another machine type.
- Prefer testing real hardware/OS behavior: reboot, power cycle, attach devices, real networking, reflash to reset.
- Prefer public product surfaces: call tRPC/API methods, write files through the files API, configure things the way the product would.
- Avoid SSHing in and surgically modifying VM state where possible; use SSH only when there is no realistic product/API path or when asserting low-level OS facts.
- Use authenticated vs unauthenticated clients intentionally.
- Test private RPCs against the unauthenticated client when relevant, to confirm they are not publicly callable.
- Aim for high test coverage: think through edge cases, failure modes, persistence, and permissions, then make sure the important ones are covered by tests.
- If different cases need guaranteed fresh state, split them into separate VM tests/scenarios.
- If multiple checks share the same state, keep them in one VM scenario rather than booting new VMs unnecessarily.
- Since VM tests are stateful and expensive, it is fine for each
test() to be one clear step in the overall scenario.
- Use clear
test() names, and add short human-readable comments for each small step so the full test scenario can be easily skimmed and understood without having to read all the code.
- For actions with unpredictable timing, such as reboots, network changes, setup jobs, and hardware detection, use retry/wait helpers with sensible timeouts.
- Make hardware state explicit: device type, boot disk, slot numbers, disk sizes, transport.
- If a test needs simulated hardware the harness does not support yet, prefer adding a clean VM harness primitive over faking hardware state inside the guest.
- If you need functionality in the VM test harness that doesn't exist, you can add it. But only add it if it's general purpose and belongs there and will likely be needed again by other tests. If it's specific to this test, just inline it in the test file.
Canonical Examples
packages/umbreld/source/modules/system/static-ip.vm.test.ts
import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'
import pRetry from 'p-retry'
import pWaitFor from 'p-wait-for'
import {createTestVm} from '../test-utilities/create-test-umbreld.js'
describe('Static IP configuration', () => {
let umbreld: Awaited<ReturnType<typeof createTestVm>>
let failed = false
let interfaceMac: string
beforeAll(async () => {
umbreld = await createTestVm({device: 'umbrel-home'})
await umbreld.vm.powerOn()
await umbreld.registerAndLogin()
})
afterAll(async () => await umbreld?.cleanup())
afterEach(({task}) => {
if (task.result?.state === 'fail') failed = true
})
beforeEach(({skip}) => {
if (failed) skip()
})
test('getNetworkInterfaces() returns the VM ethernet interface with DHCP', async () => {
const interfaces = await umbreld.client.system.getNetworkInterfaces.query()
// The VM has a single physical ethernet interface
expect(interfaces).toHaveLength(1)
const iface = interfaces[0]
interfaceMac = iface.mac
expect(iface.type).toBe('ethernet')
expect(iface.mac).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/)
expect(iface.connected).toBe(true)
expect(iface.ipMethod).toBe('dhcp')
expect(iface.configuredStaticSettings).toBeUndefined()
expect(iface.ip).toBeDefined()
expect(iface.subnetPrefix).toBeTypeOf('number')
expect(iface.gateway).toBeDefined()
expect(iface.dns).toBeInstanceOf(Array)
expect(iface.dns!.length).toBeGreaterThan(0)
})
test('switching to a static IP is reflected in getNetworkInterfaces()', async () => {
const before = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)!
// Re-apply the same DHCP-assigned values as a static config so the IP
// doesn't change, keeping QEMU port forwarding intact
const {ip, subnetPrefix, gateway, dns} = before
const setStaticIpPromise = umbreld.client.system.setStaticIp.mutate({
mac: before.mac,
ip: ip!,
subnetPrefix: subnetPrefix!,
gateway: gateway!,
dns: dns!,
})
await new Promise((resolve) => setTimeout(resolve, 2000)) // Give the mutation a moment to trigger the network change
// Wait for the connection to re-establish after down/up
let after!: Awaited<ReturnType<typeof umbreld.client.system.getNetworkInterfaces.query>>[number]
await pWaitFor(
async () => {
try {
const iface = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)
if (iface?.connected && iface.ip) {
after = iface
return true
}
return false
} catch {
return false
}
},
{interval: 100, timeout: 5000},
)
// Settings should not be persisted until the client confirms the change worked
expect(after.configuredStaticSettings).toBeUndefined()
// Confirm the static IP change
await umbreld.client.system.confirmStaticIp.mutate({ip: ip!})
// Wait for the set job to resolve (it was waiting fro the confirmation)
await setStaticIpPromise
after = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)!
// Only the method should change, everything else stays the same
expect(after.ipMethod).toBe('static')
expect(after.configuredStaticSettings).toEqual({ip, subnetPrefix, gateway, dns})
expect(after.ip).toBe(ip)
expect(after.subnetPrefix).toBe(subnetPrefix)
expect(after.gateway).toBe(gateway)
expect(after.dns).toEqual(dns)
})
test('static IP settings persist after reboot', async () => {
const before = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)!
// Power cycle the VM
await umbreld.vm.powerOff()
await umbreld.vm.powerOn()
await umbreld.login()
// Retry until the interface comes back up with the static config intact
await pRetry(
async () => {
const iface = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)
expect(iface?.connected).toBe(true)
expect(iface?.ipMethod).toBe('static')
expect(iface?.configuredStaticSettings).toEqual({
ip: before.ip!,
subnetPrefix: before.subnetPrefix!,
gateway: before.gateway!,
dns: before.dns!,
})
expect(iface?.ip).toBe(before.ip)
expect(iface?.subnetPrefix).toBe(before.subnetPrefix)
expect(iface?.gateway).toBe(before.gateway)
expect(iface?.dns).toEqual(before.dns)
},
{retries: 100, minTimeout: 100, maxTimeout: 100},
)
})
test('clearStaticIp() reverts to DHCP', async () => {
await umbreld.client.system.clearStaticIp.mutate({mac: interfaceMac})
// Wait for the connection to re-establish on DHCP
await pRetry(
async () => {
const iface = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)
expect(iface?.connected).toBe(true)
expect(iface?.ipMethod).toBe('dhcp')
expect(iface?.configuredStaticSettings).toBeUndefined()
expect(iface?.ip).toBeDefined()
expect(iface?.gateway).toBeDefined()
expect(iface?.dns).toBeInstanceOf(Array)
},
{retries: 50, minTimeout: 100, maxTimeout: 100},
)
})
})
packages/umbreld/source/modules/hardware/raid-storage.vm.test.ts
import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'
import pWaitFor from 'p-wait-for'
import {createTestVm} from '../test-utilities/create-test-umbreld.js'
describe('RAID storage mode', () => {
let umbreld: Awaited<ReturnType<typeof createTestVm>>
let firstDeviceId: string
let secondDeviceId: string
let initialTotalSpace: number
let failed = false
beforeAll(async () => {
umbreld = await createTestVm()
})
afterAll(async () => {
await umbreld?.cleanup()
})
afterEach(({task}) => {
if (task.result?.state === 'fail') failed = true
})
beforeEach(({skip}) => {
if (failed) skip()
})
test('adds NVMe device and boots VM', async () => {
await umbreld.vm.addNvme({slot: 1})
await umbreld.vm.powerOn()
})
test('detects NVMe device in slot 1', async () => {
const devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()
expect(devices).toHaveLength(1)
expect(devices[0].slot).toBe(1)
firstDeviceId = devices[0].id!
expect(firstDeviceId).toBeDefined()
})
test('registers user with RAID config (triggers reboot)', async () => {
await umbreld.signup({raidDevices: [firstDeviceId], raidType: 'storage'})
})
test('waits for RAID setup to complete and logs in', async () => {
await pWaitFor(
async () => {
try {
return await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()
} catch (error) {
// Ignore connection errors while VM is rebooting
if (error instanceof Error && error.message.includes('fetch failed')) {
return false
}
// Rethrow server errors (e.g., initialRaidSetupError)
throw error
}
},
{interval: 2000, timeout: 600_000},
)
await umbreld.login()
})
test('reports correct RAID status after setup', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
expect(status.exists).toBe(true)
expect(status.raidType).toBe('storage')
expect(status.status).toBe('ONLINE')
expect(status.devices).toHaveLength(1)
expect(status.devices![0].id).toBe(firstDeviceId)
initialTotalSpace = status.totalSpace!
expect(initialTotalSpace).toBeGreaterThan(0)
})
test('creates marker directory to verify data consistency', async () => {
await umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})
const listing = await umbreld.client.files.list.query({path: '/Home'})
expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)
})
test('shuts down and adds second SSD', async () => {
await umbreld.vm.powerOff()
await umbreld.vm.addNvme({slot: 2})
await umbreld.vm.powerOn()
})
test('logs in after adding second SSD', async () => {
await umbreld.waitForStartup({waitForUser: true})
await umbreld.login()
})
test('detects both NVMe devices after reboot', async () => {
const devices = await umbreld.client.hardware.internalStorage.getDevices.query()
expect(devices).toHaveLength(2)
const secondDevice = devices.find((d) => d.slot === 2)
expect(secondDevice).toBeDefined()
secondDeviceId = secondDevice!.id!
expect(secondDeviceId).toBeDefined()
})
test('adds second SSD to RAID array', async () => {
await umbreld.client.hardware.raid.addDevice.mutate({deviceId: secondDeviceId})
})
test('reports correct RAID status with both devices', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
expect(status.exists).toBe(true)
expect(status.raidType).toBe('storage')
expect(status.status).toBe('ONLINE')
expect(status.devices).toHaveLength(2)
})
test('has both devices in the array', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
const deviceIds = status.devices!.map((d) => d.id).sort()
expect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())
})
test('total space increased after adding second device', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
expect(status.totalSpace!).toBeGreaterThan(initialTotalSpace)
})
test('marker directory still exists after expansion', async () => {
const listing = await umbreld.client.files.list.query({path: '/Home'})
expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)
})
})
packages/umbreld/source/modules/hardware/raid-failsafe.vm.test.ts
import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'
import pWaitFor from 'p-wait-for'
import {createTestVm} from '../test-utilities/create-test-umbreld.js'
import type {ExpansionStatus} from './raid.js'
describe('RAID failsafe mode', () => {
let umbreld: Awaited<ReturnType<typeof createTestVm>>
let firstDeviceId: string
let secondDeviceId: string
let thirdDeviceId: string
let initialUsableSpace: number
let expansionSubscription: ReturnType<typeof umbreld.subscribeToEvents<ExpansionStatus>>
let failed = false
beforeAll(async () => {
umbreld = await createTestVm()
})
afterAll(async () => {
await umbreld?.cleanup()
})
afterEach(({task}) => {
if (task.result?.state === 'fail') failed = true
})
beforeEach(({skip}) => {
if (failed) skip()
})
test('adds two NVMe devices and boots VM', async () => {
await umbreld.vm.addNvme({slot: 1})
await umbreld.vm.addNvme({slot: 2})
await umbreld.vm.powerOn()
})
test('detects both NVMe devices', async () => {
const devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()
expect(devices).toHaveLength(2)
const device1 = devices.find((d) => d.slot === 1)
const device2 = devices.find((d) => d.slot === 2)
expect(device1).toBeDefined()
expect(device2).toBeDefined()
firstDeviceId = device1!.id!
secondDeviceId = device2!.id!
})
test('registers user with failsafe RAID config (triggers reboot)', async () => {
await umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'failsafe'})
})
test('waits for RAID setup to complete and logs in', async () => {
await pWaitFor(
async () => {
try {
return await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()
} catch (error) {
// Ignore connection errors while VM is rebooting
if (error instanceof Error && error.message.includes('fetch failed')) {
return false
}
// Rethrow server errors (e.g., initialRaidSetupError)
throw error
}
},
{interval: 2000, timeout: 600_000},
)
await umbreld.login()
})
test('reports correct RAID status after setup', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
expect(status.exists).toBe(true)
expect(status.raidType).toBe('failsafe')
expect(status.status).toBe('ONLINE')
expect(status.devices).toHaveLength(2)
initialUsableSpace = status.usableSpace!
expect(initialUsableSpace).toBeGreaterThan(0)
})
test('has both devices in the array', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
const deviceIds = status.devices!.map((d) => d.id).sort()
expect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())
})
test('rejects addMirror endpoint on raidz failsafe arrays', async () => {
await expect(
umbreld.client.hardware.raid.addMirror.mutate({deviceIds: [firstDeviceId, secondDeviceId]}),
).rejects.toThrow('addMirror is only supported for mirror failsafe mode')
})
test('creates marker directory to verify data consistency', async () => {
await umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})
const listing = await umbreld.client.files.list.query({path: '/Home'})
expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)
})
test('shuts down and adds third SSD', async () => {
await umbreld.vm.powerOff()
await umbreld.vm.addNvme({slot: 3})
await umbreld.vm.powerOn()
})
test('logs in after adding third SSD', async () => {
await umbreld.waitForStartup({waitForUser: true})
await umbreld.login()
})
test('detects all three NVMe devices after reboot', async () => {
const devices = await umbreld.client.hardware.internalStorage.getDevices.query()
expect(devices).toHaveLength(3)
const thirdDevice = devices.find((d) => d.slot === 3)
expect(thirdDevice).toBeDefined()
thirdDeviceId = thirdDevice!.id!
expect(thirdDeviceId).toBeDefined()
})
test('adds third SSD to RAID array and subscribes to expansion events', async () => {
// Subscribe to expansion events before adding the device
expansionSubscription = umbreld.subscribeToEvents<ExpansionStatus>('raid:expansion-progress')
await umbreld.client.hardware.raid.addDevice.mutate({deviceId: thirdDeviceId})
})
test('reports correct RAID status with three devices', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
expect(status.exists).toBe(true)
expect(status.raidType).toBe('failsafe')
expect(status.status).toBe('ONLINE')
expect(status.devices).toHaveLength(3)
})
test('has all three devices in the array', async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
const deviceIds = status.devices!.map((d) => d.id).sort()
expect(deviceIds).toEqual([firstDeviceId, secondDeviceId, thirdDeviceId].sort())
})
// In RAIDZ1, when attaching a new device, the expansion is async.
// Wait for expansion to complete and verify events only increase.
test('receives expansion events via WebSocket', async () => {
// Wait for expansion to complete via events
await pWaitFor(
() => {
const events = expansionSubscription.collected
const lastEvent = events[events.length - 1]
return lastEvent?.state === 'finished' && lastEvent?.progress === 100
},
{interval: 1000, timeout: 30_000},
)
// Unsubscribe since expansion is complete
expansionSubscription.unsubscribe()
const events = expansionSubscription.collected
expect(events.length).toBeGreaterThan(1)
// Verify events have correct structure
for (const event of events) {
expect(['expanding', 'finished', 'canceled']).toContain(event.state)
expect(event.progress).toBeGreaterThanOrEqual(0)
expect(event.progress).toBeLessThanOrEqual(100)
}
// Verify progress only increased across events
const progressFromEvents = events.map((e) => e.progress)
for (let i = 1; i < progressFromEvents.length; i++) {
expect(progressFromEvents[i]).toBeGreaterThanOrEqual(progressFromEvents[i - 1])
}
// Verify we started below 100 and ended at 100
expect(progressFromEvents[0]).toBeLessThan(100)
expect(progressFromEvents[progressFromEvents.length - 1]).toBe(100)
})
test('usable space increased after expansion', async () => {
await pWaitFor(
async () => {
const status = await umbreld.client.hardware.raid.getStatus.query()
return status.usableSpace! > initialUsableSpace
},
{interval: 1000, timeout: 60_000},
)
})
test('marker directory still exists after expansion', async () => {
const listing = await umbreld.client.files.list.query({path: '/Home'})
expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)
})
})
1---2name: umbreld-vm-test-authoring3description: Use this skill when adding, converting, or reviewing umbreld VM tests in the Umbrel repo. It describes the preferred style for clear VM tests that boot umbrelOS and exercise real OS, hardware, storage, networking, and reboot behavior.4---56# Umbrel VM Test Authoring78## Guidance910- Read other relevant VM tests when you need inspiration.11- Read `packages/umbreld/source/modules/test-utilities/create-test-umbreld.ts` to understand the VM test harness API before adding new helpers or guessing how VM lifecycle, auth clients, SSH, and device operations work.12- Use the `umbrel-home` machine by default because it is the simplest, unless the behavior specifically needs another machine type.13- Prefer testing real hardware/OS behavior: reboot, power cycle, attach devices, real networking, reflash to reset.14- Prefer public product surfaces: call tRPC/API methods, write files through the files API, configure things the way the product would.15- Avoid SSHing in and surgically modifying VM state where possible; use SSH only when there is no realistic product/API path or when asserting low-level OS facts.16- Use authenticated vs unauthenticated clients intentionally.17- Test private RPCs against the unauthenticated client when relevant, to confirm they are not publicly callable.18- Aim for high test coverage: think through edge cases, failure modes, persistence, and permissions, then make sure the important ones are covered by tests.19- If different cases need guaranteed fresh state, split them into separate VM tests/scenarios.20- If multiple checks share the same state, keep them in one VM scenario rather than booting new VMs unnecessarily.21- Since VM tests are stateful and expensive, it is fine for each `test()` to be one clear step in the overall scenario.22- Use clear `test()` names, and add short human-readable comments for each small step so the full test scenario can be easily skimmed and understood without having to read all the code.23- For actions with unpredictable timing, such as reboots, network changes, setup jobs, and hardware detection, use retry/wait helpers with sensible timeouts.24- Make hardware state explicit: device type, boot disk, slot numbers, disk sizes, transport.25- If a test needs simulated hardware the harness does not support yet, prefer adding a clean VM harness primitive over faking hardware state inside the guest.26- If you need functionality in the VM test harness that doesn't exist, you can add it. But only add it if it's general purpose and belongs there and will likely be needed again by other tests. If it's specific to this test, just inline it in the test file.2728## Canonical Examples2930### `packages/umbreld/source/modules/system/static-ip.vm.test.ts`3132```ts33import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'34import pRetry from 'p-retry'35import pWaitFor from 'p-wait-for'3637import {createTestVm} from '../test-utilities/create-test-umbreld.js'3839describe('Static IP configuration', () => {40 let umbreld: Awaited<ReturnType<typeof createTestVm>>41 let failed = false42 let interfaceMac: string4344 beforeAll(async () => {45 umbreld = await createTestVm({device: 'umbrel-home'})46 await umbreld.vm.powerOn()47 await umbreld.registerAndLogin()48 })4950 afterAll(async () => await umbreld?.cleanup())5152 afterEach(({task}) => {53 if (task.result?.state === 'fail') failed = true54 })5556 beforeEach(({skip}) => {57 if (failed) skip()58 })5960 test('getNetworkInterfaces() returns the VM ethernet interface with DHCP', async () => {61 const interfaces = await umbreld.client.system.getNetworkInterfaces.query()6263 // The VM has a single physical ethernet interface64 expect(interfaces).toHaveLength(1)65 const iface = interfaces[0]66 interfaceMac = iface.mac6768 expect(iface.type).toBe('ethernet')69 expect(iface.mac).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/)70 expect(iface.connected).toBe(true)71 expect(iface.ipMethod).toBe('dhcp')72 expect(iface.configuredStaticSettings).toBeUndefined()73 expect(iface.ip).toBeDefined()74 expect(iface.subnetPrefix).toBeTypeOf('number')75 expect(iface.gateway).toBeDefined()76 expect(iface.dns).toBeInstanceOf(Array)77 expect(iface.dns!.length).toBeGreaterThan(0)78 })7980 test('switching to a static IP is reflected in getNetworkInterfaces()', async () => {81 const before = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)!82 // Re-apply the same DHCP-assigned values as a static config so the IP83 // doesn't change, keeping QEMU port forwarding intact84 const {ip, subnetPrefix, gateway, dns} = before8586 const setStaticIpPromise = umbreld.client.system.setStaticIp.mutate({87 mac: before.mac,88 ip: ip!,89 subnetPrefix: subnetPrefix!,90 gateway: gateway!,91 dns: dns!,92 })9394 await new Promise((resolve) => setTimeout(resolve, 2000)) // Give the mutation a moment to trigger the network change9596 // Wait for the connection to re-establish after down/up97 let after!: Awaited<ReturnType<typeof umbreld.client.system.getNetworkInterfaces.query>>[number]98 await pWaitFor(99 async () => {100 try {101 const iface = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)102 if (iface?.connected && iface.ip) {103 after = iface104 return true105 }106 return false107 } catch {108 return false109 }110 },111 {interval: 100, timeout: 5000},112 )113114 // Settings should not be persisted until the client confirms the change worked115 expect(after.configuredStaticSettings).toBeUndefined()116117 // Confirm the static IP change118 await umbreld.client.system.confirmStaticIp.mutate({ip: ip!})119120 // Wait for the set job to resolve (it was waiting fro the confirmation)121 await setStaticIpPromise122 after = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)!123124 // Only the method should change, everything else stays the same125 expect(after.ipMethod).toBe('static')126 expect(after.configuredStaticSettings).toEqual({ip, subnetPrefix, gateway, dns})127 expect(after.ip).toBe(ip)128 expect(after.subnetPrefix).toBe(subnetPrefix)129 expect(after.gateway).toBe(gateway)130 expect(after.dns).toEqual(dns)131 })132133 test('static IP settings persist after reboot', async () => {134 const before = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)!135136 // Power cycle the VM137 await umbreld.vm.powerOff()138 await umbreld.vm.powerOn()139 await umbreld.login()140141 // Retry until the interface comes back up with the static config intact142 await pRetry(143 async () => {144 const iface = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)145 expect(iface?.connected).toBe(true)146 expect(iface?.ipMethod).toBe('static')147 expect(iface?.configuredStaticSettings).toEqual({148 ip: before.ip!,149 subnetPrefix: before.subnetPrefix!,150 gateway: before.gateway!,151 dns: before.dns!,152 })153 expect(iface?.ip).toBe(before.ip)154 expect(iface?.subnetPrefix).toBe(before.subnetPrefix)155 expect(iface?.gateway).toBe(before.gateway)156 expect(iface?.dns).toEqual(before.dns)157 },158 {retries: 100, minTimeout: 100, maxTimeout: 100},159 )160 })161162 test('clearStaticIp() reverts to DHCP', async () => {163 await umbreld.client.system.clearStaticIp.mutate({mac: interfaceMac})164165 // Wait for the connection to re-establish on DHCP166 await pRetry(167 async () => {168 const iface = (await umbreld.client.system.getNetworkInterfaces.query()).find((i) => i.mac === interfaceMac)169 expect(iface?.connected).toBe(true)170 expect(iface?.ipMethod).toBe('dhcp')171 expect(iface?.configuredStaticSettings).toBeUndefined()172 expect(iface?.ip).toBeDefined()173 expect(iface?.gateway).toBeDefined()174 expect(iface?.dns).toBeInstanceOf(Array)175 },176 {retries: 50, minTimeout: 100, maxTimeout: 100},177 )178 })179})180```181182### `packages/umbreld/source/modules/hardware/raid-storage.vm.test.ts`183184```ts185import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'186import pWaitFor from 'p-wait-for'187188import {createTestVm} from '../test-utilities/create-test-umbreld.js'189190describe('RAID storage mode', () => {191 let umbreld: Awaited<ReturnType<typeof createTestVm>>192 let firstDeviceId: string193 let secondDeviceId: string194 let initialTotalSpace: number195 let failed = false196197 beforeAll(async () => {198 umbreld = await createTestVm()199 })200201 afterAll(async () => {202 await umbreld?.cleanup()203 })204205 afterEach(({task}) => {206 if (task.result?.state === 'fail') failed = true207 })208209 beforeEach(({skip}) => {210 if (failed) skip()211 })212213 test('adds NVMe device and boots VM', async () => {214 await umbreld.vm.addNvme({slot: 1})215 await umbreld.vm.powerOn()216 })217218 test('detects NVMe device in slot 1', async () => {219 const devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()220 expect(devices).toHaveLength(1)221 expect(devices[0].slot).toBe(1)222 firstDeviceId = devices[0].id!223 expect(firstDeviceId).toBeDefined()224 })225226 test('registers user with RAID config (triggers reboot)', async () => {227 await umbreld.signup({raidDevices: [firstDeviceId], raidType: 'storage'})228 })229230 test('waits for RAID setup to complete and logs in', async () => {231 await pWaitFor(232 async () => {233 try {234 return await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()235 } catch (error) {236 // Ignore connection errors while VM is rebooting237 if (error instanceof Error && error.message.includes('fetch failed')) {238 return false239 }240 // Rethrow server errors (e.g., initialRaidSetupError)241 throw error242 }243 },244 {interval: 2000, timeout: 600_000},245 )246 await umbreld.login()247 })248249 test('reports correct RAID status after setup', async () => {250 const status = await umbreld.client.hardware.raid.getStatus.query()251 expect(status.exists).toBe(true)252 expect(status.raidType).toBe('storage')253 expect(status.status).toBe('ONLINE')254 expect(status.devices).toHaveLength(1)255 expect(status.devices![0].id).toBe(firstDeviceId)256 initialTotalSpace = status.totalSpace!257 expect(initialTotalSpace).toBeGreaterThan(0)258 })259260 test('creates marker directory to verify data consistency', async () => {261 await umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})262 const listing = await umbreld.client.files.list.query({path: '/Home'})263 expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)264 })265266 test('shuts down and adds second SSD', async () => {267 await umbreld.vm.powerOff()268 await umbreld.vm.addNvme({slot: 2})269 await umbreld.vm.powerOn()270 })271272 test('logs in after adding second SSD', async () => {273 await umbreld.waitForStartup({waitForUser: true})274 await umbreld.login()275 })276277 test('detects both NVMe devices after reboot', async () => {278 const devices = await umbreld.client.hardware.internalStorage.getDevices.query()279 expect(devices).toHaveLength(2)280 const secondDevice = devices.find((d) => d.slot === 2)281 expect(secondDevice).toBeDefined()282 secondDeviceId = secondDevice!.id!283 expect(secondDeviceId).toBeDefined()284 })285286 test('adds second SSD to RAID array', async () => {287 await umbreld.client.hardware.raid.addDevice.mutate({deviceId: secondDeviceId})288 })289290 test('reports correct RAID status with both devices', async () => {291 const status = await umbreld.client.hardware.raid.getStatus.query()292 expect(status.exists).toBe(true)293 expect(status.raidType).toBe('storage')294 expect(status.status).toBe('ONLINE')295 expect(status.devices).toHaveLength(2)296 })297298 test('has both devices in the array', async () => {299 const status = await umbreld.client.hardware.raid.getStatus.query()300 const deviceIds = status.devices!.map((d) => d.id).sort()301 expect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())302 })303304 test('total space increased after adding second device', async () => {305 const status = await umbreld.client.hardware.raid.getStatus.query()306 expect(status.totalSpace!).toBeGreaterThan(initialTotalSpace)307 })308309 test('marker directory still exists after expansion', async () => {310 const listing = await umbreld.client.files.list.query({path: '/Home'})311 expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)312 })313})314```315316### `packages/umbreld/source/modules/hardware/raid-failsafe.vm.test.ts`317318```ts319import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'320import pWaitFor from 'p-wait-for'321322import {createTestVm} from '../test-utilities/create-test-umbreld.js'323import type {ExpansionStatus} from './raid.js'324325describe('RAID failsafe mode', () => {326 let umbreld: Awaited<ReturnType<typeof createTestVm>>327 let firstDeviceId: string328 let secondDeviceId: string329 let thirdDeviceId: string330 let initialUsableSpace: number331 let expansionSubscription: ReturnType<typeof umbreld.subscribeToEvents<ExpansionStatus>>332 let failed = false333334 beforeAll(async () => {335 umbreld = await createTestVm()336 })337338 afterAll(async () => {339 await umbreld?.cleanup()340 })341342 afterEach(({task}) => {343 if (task.result?.state === 'fail') failed = true344 })345346 beforeEach(({skip}) => {347 if (failed) skip()348 })349350 test('adds two NVMe devices and boots VM', async () => {351 await umbreld.vm.addNvme({slot: 1})352 await umbreld.vm.addNvme({slot: 2})353 await umbreld.vm.powerOn()354 })355356 test('detects both NVMe devices', async () => {357 const devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()358 expect(devices).toHaveLength(2)359 const device1 = devices.find((d) => d.slot === 1)360 const device2 = devices.find((d) => d.slot === 2)361 expect(device1).toBeDefined()362 expect(device2).toBeDefined()363 firstDeviceId = device1!.id!364 secondDeviceId = device2!.id!365 })366367 test('registers user with failsafe RAID config (triggers reboot)', async () => {368 await umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'failsafe'})369 })370371 test('waits for RAID setup to complete and logs in', async () => {372 await pWaitFor(373 async () => {374 try {375 return await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()376 } catch (error) {377 // Ignore connection errors while VM is rebooting378 if (error instanceof Error && error.message.includes('fetch failed')) {379 return false380 }381 // Rethrow server errors (e.g., initialRaidSetupError)382 throw error383 }384 },385 {interval: 2000, timeout: 600_000},386 )387 await umbreld.login()388 })389390 test('reports correct RAID status after setup', async () => {391 const status = await umbreld.client.hardware.raid.getStatus.query()392 expect(status.exists).toBe(true)393 expect(status.raidType).toBe('failsafe')394 expect(status.status).toBe('ONLINE')395 expect(status.devices).toHaveLength(2)396 initialUsableSpace = status.usableSpace!397 expect(initialUsableSpace).toBeGreaterThan(0)398 })399400 test('has both devices in the array', async () => {401 const status = await umbreld.client.hardware.raid.getStatus.query()402 const deviceIds = status.devices!.map((d) => d.id).sort()403 expect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())404 })405406 test('rejects addMirror endpoint on raidz failsafe arrays', async () => {407 await expect(408 umbreld.client.hardware.raid.addMirror.mutate({deviceIds: [firstDeviceId, secondDeviceId]}),409 ).rejects.toThrow('addMirror is only supported for mirror failsafe mode')410 })411412 test('creates marker directory to verify data consistency', async () => {413 await umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})414 const listing = await umbreld.client.files.list.query({path: '/Home'})415 expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)416 })417418 test('shuts down and adds third SSD', async () => {419 await umbreld.vm.powerOff()420 await umbreld.vm.addNvme({slot: 3})421 await umbreld.vm.powerOn()422 })423424 test('logs in after adding third SSD', async () => {425 await umbreld.waitForStartup({waitForUser: true})426 await umbreld.login()427 })428429 test('detects all three NVMe devices after reboot', async () => {430 const devices = await umbreld.client.hardware.internalStorage.getDevices.query()431 expect(devices).toHaveLength(3)432 const thirdDevice = devices.find((d) => d.slot === 3)433 expect(thirdDevice).toBeDefined()434 thirdDeviceId = thirdDevice!.id!435 expect(thirdDeviceId).toBeDefined()436 })437438 test('adds third SSD to RAID array and subscribes to expansion events', async () => {439 // Subscribe to expansion events before adding the device440 expansionSubscription = umbreld.subscribeToEvents<ExpansionStatus>('raid:expansion-progress')441442 await umbreld.client.hardware.raid.addDevice.mutate({deviceId: thirdDeviceId})443 })444445 test('reports correct RAID status with three devices', async () => {446 const status = await umbreld.client.hardware.raid.getStatus.query()447 expect(status.exists).toBe(true)448 expect(status.raidType).toBe('failsafe')449 expect(status.status).toBe('ONLINE')450 expect(status.devices).toHaveLength(3)451 })452453 test('has all three devices in the array', async () => {454 const status = await umbreld.client.hardware.raid.getStatus.query()455 const deviceIds = status.devices!.map((d) => d.id).sort()456 expect(deviceIds).toEqual([firstDeviceId, secondDeviceId, thirdDeviceId].sort())457 })458459 // In RAIDZ1, when attaching a new device, the expansion is async.460 // Wait for expansion to complete and verify events only increase.461 test('receives expansion events via WebSocket', async () => {462 // Wait for expansion to complete via events463 await pWaitFor(464 () => {465 const events = expansionSubscription.collected466 const lastEvent = events[events.length - 1]467 return lastEvent?.state === 'finished' && lastEvent?.progress === 100468 },469 {interval: 1000, timeout: 30_000},470 )471472 // Unsubscribe since expansion is complete473 expansionSubscription.unsubscribe()474475 const events = expansionSubscription.collected476 expect(events.length).toBeGreaterThan(1)477478 // Verify events have correct structure479 for (const event of events) {480 expect(['expanding', 'finished', 'canceled']).toContain(event.state)481 expect(event.progress).toBeGreaterThanOrEqual(0)482 expect(event.progress).toBeLessThanOrEqual(100)483 }484485 // Verify progress only increased across events486 const progressFromEvents = events.map((e) => e.progress)487 for (let i = 1; i < progressFromEvents.length; i++) {488 expect(progressFromEvents[i]).toBeGreaterThanOrEqual(progressFromEvents[i - 1])489 }490491 // Verify we started below 100 and ended at 100492 expect(progressFromEvents[0]).toBeLessThan(100)493 expect(progressFromEvents[progressFromEvents.length - 1]).toBe(100)494 })495496 test('usable space increased after expansion', async () => {497 await pWaitFor(498 async () => {499 const status = await umbreld.client.hardware.raid.getStatus.query()500 return status.usableSpace! > initialUsableSpace501 },502 {interval: 1000, timeout: 60_000},503 )504 })505506 test('marker directory still exists after expansion', async () => {507 const listing = await umbreld.client.files.list.query({path: '/Home'})508 expect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)509 })510})511```