Files
BrowserOS/packages/browseros-agent/apps/server/tests/lib/vm/vm-runtime.test.ts
Nikhil 492f3fcdf2 feat(openclaw): prewarm ghcr image in vm (#887)
* feat(openclaw): add gateway image inspection

* feat(openclaw): pull gateway image from registry

* refactor(vm): decouple readiness from image cache

* refactor(openclaw): remove vm cache from runtime factory

* feat(openclaw): detect current gateway image

* feat(openclaw): prewarm vm runtime and reuse current gateway

* feat(openclaw): prewarm runtime on server startup

* refactor(vm): remove browseros image cache runtime

* refactor(build-tools): remove openclaw tarball pipeline

* chore: self-review fixes

* fix(openclaw): suppress prewarm pull progress logs

* fix(openclaw): address review feedback

* fix(openclaw): resolve review findings

* fix(dev): stop stale watch supervisors
2026-04-30 11:18:11 -07:00

345 lines
9.4 KiB
TypeScript

/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises'
import { join } from 'node:path'
import { VmNotReadyError } from '../../../src/lib/vm/errors'
import { VM_NAME } from '../../../src/lib/vm/paths'
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
import { fakeLimactl } from '../../__helpers__/fake-limactl'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('VmRuntime', () => {
let root: string
let limaHome: string
let logPath: string
let templatePath: string
beforeEach(async () => {
root = await mkdtemp('/tmp/vmrt-')
limaHome = join(root, 'lima')
logPath = join(root, 'limactl.log')
templatePath = join(root, 'browseros-vm.yaml')
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('provisions a fresh VM and waits for rootless nerdctl', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
await expect(
readFile(join(limaHome, `${VM_NAME}.yaml`), 'utf8'),
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
})
it('returns fast when the VM is already running', async () => {
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
create: { stderr: 'should not create', exit: 9 },
start: { stderr: 'should not start', exit: 9 },
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain('ARGS:list --format json')
expect(log).not.toContain('ARGS:create')
expect(log).not.toContain('ARGS:start')
})
it('starts an existing stopped VM without recreating it', async () => {
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Stopped', dir: limaHome },
]),
},
start: {},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
expect(log).not.toContain('ARGS:create')
})
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
stop: {},
delete: {},
create: {},
start: {},
},
logPath,
)
const sshPath = await fakeRootfulThenReadySsh(root, logPath)
await writeSshConfig(limaHome)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
expect(log).toContain(
`lima-${VM_NAME} 'sh' '-lc' 'cat /etc/browseros-vm-version 2>/dev/null || true'`,
)
expect(log).toContain(`ARGS:stop ${VM_NAME}`)
expect(log).toContain(`ARGS:delete --force ${VM_NAME}`)
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
})
it('treats stopVm as idempotent when the VM is already stopped', async () => {
const limactlPath = await fakeLimactl(
{ stop: { stderr: 'instance is not running', exit: 1 } },
logPath,
)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
it('requires a bundled Lima template for fresh VM provisioning', async () => {
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.ensureReady()).rejects.toThrow('Lima template path')
})
it('throws VmNotReadyError when rootless nerdctl never becomes ready', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareFailingSsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 10,
readinessPollMs: 1,
})
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
})
it('exposes a reset stub with a follow-up-plan message', async () => {
const limactlPath = await fakeLimactl({}, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.reset('bad disk')).rejects.toThrow(
'VmRuntime.reset is not implemented yet',
)
})
it('does not auto-reset when rootless nerdctl readiness fails', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareFailingSsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 10,
readinessPollMs: 1,
})
let resetCalled = false
runtime.reset = async () => {
resetCalled = true
throw new Error('reset called')
}
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
expect(resetCalled).toBe(false)
})
it('delegates runCommand through ssh', async () => {
const sshPath = await fakeSsh({}, logPath)
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(sshConfig, '')
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
sshPath,
browserosRoot: root,
})
await expect(runtime.runCommand(['nerdctl', 'version'])).resolves.toBe(0)
const log = await readFile(logPath, 'utf8')
expect(log).toContain(
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'version'`,
)
})
it('resolves and caches the VM default gateway through ssh', async () => {
const sshPath = await fakeSsh(
{
stdout:
'default via 192.168.5.2 dev eth0 proto dhcp src 192.168.5.15 metric 100\n',
},
logPath,
)
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(sshConfig, '')
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
sshPath,
browserosRoot: root,
})
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
const log = await readFile(logPath, 'utf8')
expect(log.match(/'ip' '-4' 'route' 'show' 'default'/g)).toHaveLength(1)
})
})
async function prepareReadySsh(
limaHome: string,
logPath: string,
): Promise<string> {
await writeSshConfig(limaHome)
return fakeSsh({}, logPath)
}
async function prepareFailingSsh(
limaHome: string,
logPath: string,
): Promise<string> {
await writeSshConfig(limaHome)
return fakeSsh(
{
stderr:
'rootless containerd not running? stat /run/user/501/containerd-rootless: no such file or directory',
exit: 1,
},
logPath,
)
}
async function writeSshConfig(limaHome: string): Promise<void> {
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(join(limaHome, VM_NAME, 'ssh.config'), '')
}
async function fakeRootfulThenReadySsh(
root: string,
logPath: string,
): Promise<string> {
const path = join(root, 'ssh-rootful-then-ready')
const counterPath = join(root, 'ssh-rootful-then-ready.count')
const body = `#!/usr/bin/env bash
set -u
echo "ARGS:$*" >> "${logPath}"
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
next=$((count + 1))
printf '%s' "$next" > "${counterPath}"
case "$count" in
0)
echo "rootless containerd not running" >&2
exit 1
;;
1)
printf 'runtime:containerd\\n'
exit 0
;;
*)
exit 0
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}