Files
BrowserOS/packages/browseros-agent/apps/server/tests/lib/container/container-cli.test.ts
Nikhil fd5aba249b fix: stabilize OpenClaw gateway startup (#888)
* feat(server): add shared process lock helper

* feat(container): add container name reconciliation helpers

* feat(openclaw): serialize lifecycle across processes

* fix(openclaw): reconcile fixed gateway container startup

* test(openclaw): cover lifecycle race recovery

* fix(server): satisfy process lock error override

* fix(openclaw): address review feedback

* test(openclaw): align serialization mock with image check
2026-04-30 11:31:40 -07:00

342 lines
10 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 { ContainerCli } from '../../../src/lib/container/container-cli'
import {
ContainerCliError,
ContainerNameInUseError,
} from '../../../src/lib/vm/errors'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('ContainerCli', () => {
let tempDir: string
let logPath: string
beforeEach(async () => {
tempDir = await mkdtemp('/tmp/container-cli-')
logPath = join(tempDir, 'ssh.log')
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
it('checks image existence with nerdctl image inspect', async () => {
const sshPath = await fakeSsh({}, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(true)
const sshConfig = sshConfigPath(tempDir)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfig)} 'nerdctl' 'image' 'inspect' 'openclaw:v1'`,
)
})
it('returns false when image inspect exits non-zero', async () => {
const sshPath = await fakeSsh({ stderr: 'missing', exit: 1 }, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
})
it('reads a container configured image ref', async () => {
const sshPath = await fakeSsh(
{ stdout: 'ghcr.io/openclaw/openclaw:2026.4.12\n' },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.containerImageRef('gateway')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'inspect' '--format' '{{.Config.Image}}' 'gateway'`,
)
})
it('returns null when reading a missing container image ref', async () => {
const sshPath = await fakeSsh(
{
stderr: 'no such container',
exit: 1,
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.containerImageRef('missing')).resolves.toBeNull()
})
it('pulls images with progress and throws typed command errors', async () => {
const sshPath = await fakeSsh(
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
const lines: string[] = []
const error = await cli
.pullImage('openclaw:v1', (line) => lines.push(line))
.catch((err) => err)
expect(error).toBeInstanceOf(ContainerCliError)
expect(error.exitCode).toBe(2)
expect(error.stderr).toBe('denied')
expect(lines).toContain('pulling')
expect(lines).toContain('denied')
})
it('creates containers from typed specs', async () => {
const sshPath = await fakeSsh({}, logPath)
const cli = await createCli(sshPath, tempDir)
await cli.createContainer({
name: 'gateway',
image: 'openclaw:v1',
restart: 'unless-stopped',
ports: [{ hostIp: '127.0.0.1', hostPort: 18789, containerPort: 18789 }],
envFile: '/mnt/browseros/vm/openclaw/.env',
env: { HOME: '/home/node', NODE_ENV: 'production' },
mounts: [
{
source: '/mnt/browseros/vm/openclaw',
target: '/home/node',
readonly: true,
},
],
addHosts: ['host.containers.internal:192.168.5.2'],
health: {
cmd: 'curl -sf http://127.0.0.1:18789/healthz',
interval: '30s',
timeout: '10s',
retries: 3,
},
command: ['node', 'dist/index.js', 'gateway'],
})
await expect(readFile(logPath, 'utf8')).resolves.toContain(
[
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'create'`,
"'--name' 'gateway'",
"'--restart' 'unless-stopped'",
"'-p' '127.0.0.1:18789:18789'",
"'--env-file' '/mnt/browseros/vm/openclaw/.env'",
"'-e' 'HOME=/home/node'",
"'-e' 'NODE_ENV=production'",
"'-v' '/mnt/browseros/vm/openclaw:/home/node:ro'",
"'--add-host' 'host.containers.internal:192.168.5.2'",
"'--health-cmd' 'curl -sf http://127.0.0.1:18789/healthz'",
"'--health-interval' '30s'",
"'--health-timeout' '10s'",
"'--health-retries' '3'",
"'openclaw:v1' 'node' 'dist/index.js' 'gateway'",
].join(' '),
)
})
it('starts, stops, removes, execs, and lists containers', async () => {
const sshPath = await fakeSsh({ stdout: 'gateway\nworker\n' }, logPath)
const cli = await createCli(sshPath, tempDir)
await cli.startContainer('gateway')
await cli.stopContainer('gateway')
await cli.removeContainer('gateway', { force: true })
await expect(cli.exec('gateway', ['node', '--version'])).resolves.toBe(0)
await expect(cli.ps({ namesOnly: true })).resolves.toEqual([
'gateway',
'worker',
])
const log = await readFile(logPath, 'utf8')
expect(log).toContain("lima-browseros-vm 'nerdctl' 'start' 'gateway'")
expect(log).toContain("lima-browseros-vm 'nerdctl' 'stop' 'gateway'")
expect(log).toContain("lima-browseros-vm 'nerdctl' 'rm' '-f' 'gateway'")
expect(log).toContain(
"lima-browseros-vm 'nerdctl' 'exec' 'gateway' 'node' '--version'",
)
expect(log).toContain(
"lima-browseros-vm 'nerdctl' 'ps' '--format' '{{.Names}}'",
)
})
it('inspects a container by name', async () => {
const sshPath = await fakeSsh(
{
stdout: JSON.stringify({
ID: 'abc123',
Name: 'gateway',
Config: { Image: 'openclaw:v1' },
State: { Status: 'running', Running: true },
}),
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).resolves.toEqual({
id: 'abc123',
name: 'gateway',
image: 'openclaw:v1',
status: 'running',
running: true,
})
await expect(readFile(logPath, 'utf8')).resolves.toContain(
"lima-browseros-vm 'nerdctl' 'container' 'inspect' '--format' '{{json .}}' 'gateway'",
)
})
it('returns null when inspected containers are absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).resolves.toBeNull()
})
it('does not treat unrelated not found errors as absent containers', async () => {
const sshPath = await fakeSsh(
{ stderr: 'network interface not found', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).rejects.toBeInstanceOf(
ContainerCliError,
)
})
it('waits until a container name is no longer resolvable', async () => {
const sshPath = await fakeSshContainerExistsThenMissing(tempDir, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(
cli.waitForContainerNameRelease('gateway', {
timeoutMs: 500,
intervalMs: 5,
}),
).resolves.toBeUndefined()
const inspectCalls = (await readFile(logPath, 'utf8'))
.split('\n')
.filter((line) => line.includes("'container' 'inspect'"))
expect(inspectCalls).toHaveLength(2)
})
it('classifies create name-store collisions as name-in-use errors', async () => {
const sshPath = await fakeSsh(
{
stderr:
'name-store error\nname "gateway" is already used by ID "abc123"',
exit: 1,
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
const error = await cli
.createContainer({ name: 'gateway', image: 'openclaw:v1' })
.catch((err) => err)
expect(error).toBeInstanceOf(ContainerNameInUseError)
expect(error.containerName).toBe('gateway')
expect(error.stderr).toContain('name "gateway" is already used')
})
it('tolerates removal when the container is already absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.removeContainer('gateway', { force: true })).resolves.toBe(
undefined,
)
})
it('tails logs and returns a stop handle', async () => {
const sshPath = await fakeSsh({ stdout: 'line\n' }, logPath)
const cli = await createCli(sshPath, tempDir)
const lines: string[] = []
const stop = cli.tailLogs('gateway', (line) => lines.push(line))
for (let attempts = 0; attempts < 50 && lines.length === 0; attempts += 1) {
await Bun.sleep(10)
}
stop()
expect(lines).toEqual(['line'])
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'logs' '-f' '-n' '0' 'gateway'`,
)
})
})
async function createCli(
sshPath: string,
tempDir: string,
): Promise<ContainerCli> {
const configPath = sshConfigPath(tempDir)
await mkdir(join(tempDir, 'lima', 'browseros-vm'), { recursive: true })
await writeFile(configPath, '')
return new ContainerCli({
limactlPath: 'unused',
limaHome: join(tempDir, 'lima'),
sshPath,
vmName: 'browseros-vm',
})
}
function sshConfigPath(tempDir: string): string {
return join(tempDir, 'lima', 'browseros-vm', 'ssh.config')
}
function sshPrefix(configPath: string): string {
return `ARGS:-F ${configPath} lima-browseros-vm`
}
async function fakeSshContainerExistsThenMissing(
tempDir: string,
logPath: string,
): Promise<string> {
const path = join(tempDir, 'ssh-container-exists-then-missing')
const counterPath = join(tempDir, 'ssh-container-exists-then-missing.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)
printf '{"ID":"abc123","Name":"gateway","Config":{"Image":"openclaw:v1"},"State":{"Status":"exited","Running":false}}'
exit 0
;;
*)
echo "no such container" >&2
exit 1
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}