chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import NIOPosix
|
||||
import Testing
|
||||
|
||||
@testable import CloudHypervisor
|
||||
|
||||
@Suite("CloudHypervisor.Client")
|
||||
struct ClientTests {
|
||||
private static let group = MultiThreadedEventLoopGroup.singleton
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@Test("Client init succeeds with file:// URL")
|
||||
func initSucceeds() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let socketURL = URL(filePath: server.socketPath)
|
||||
let _ = try CloudHypervisor.Client(
|
||||
socketPath: socketURL,
|
||||
eventLoopGroup: Self.group
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Invalid socket path
|
||||
|
||||
@Test("Client init throws .invalidSocketPath for non-file URL")
|
||||
func initThrowsForNonFileURL() throws {
|
||||
let url = try #require(URL(string: "https://example.com"))
|
||||
#expect(throws: CloudHypervisor.Error.self) {
|
||||
try CloudHypervisor.Client(socketPath: url, eventLoopGroup: Self.group)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Non-2xx response
|
||||
|
||||
@Test("Non-2xx response throws .http with correct status")
|
||||
func non2xxThrowsHTTPError() async throws {
|
||||
let body = Data("not found".utf8)
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.notFound, body: body)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let socketURL = URL(filePath: server.socketPath)
|
||||
let client = try CloudHypervisor.Client(socketPath: socketURL, eventLoopGroup: Self.group)
|
||||
|
||||
struct Dummy: Decodable, Sendable {}
|
||||
|
||||
do {
|
||||
let _: Dummy = try await client.get("/api/v1/missing")
|
||||
Issue.record("Expected .http error but call succeeded")
|
||||
} catch let err as CloudHypervisor.Error {
|
||||
guard case .http(let status, let respBody) = err else {
|
||||
Issue.record("Expected .http, got \(err)")
|
||||
return
|
||||
}
|
||||
#expect(status == .notFound)
|
||||
#expect(respBody == body)
|
||||
} catch {
|
||||
Issue.record("Expected CloudHypervisor.Error but got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - vmmPing
|
||||
|
||||
@Test("vmmPing sends GET /api/v1/vmm.ping and decodes VmmPingResponse")
|
||||
func vmmPing() async throws {
|
||||
let expected = CloudHypervisor.VmmPingResponse(version: "v40.0", pid: 12345)
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
(try? StubResponse.json(expected)) ?? StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
let result = try await client.vmmPing()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .GET)
|
||||
#expect(recorded[0].uri == "/api/v1/vmm.ping")
|
||||
#expect(recorded[0].body.isEmpty)
|
||||
#expect(result.version == "v40.0")
|
||||
#expect(result.pid == 12345)
|
||||
}
|
||||
|
||||
// MARK: - vmmShutdown
|
||||
|
||||
@Test("vmmShutdown sends PUT /api/v1/vmm.shutdown and returns without throwing")
|
||||
func vmmShutdown() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
try await client.vmmShutdown()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vmm.shutdown")
|
||||
}
|
||||
|
||||
// MARK: - vmmInfo
|
||||
|
||||
@Test("vmmInfo sends GET /api/v1/vmm.info and decodes VmmInfo")
|
||||
func vmmInfo() async throws {
|
||||
let expected = CloudHypervisor.VmmInfo(version: "v40.0", pid: 99)
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
(try? StubResponse.json(expected)) ?? StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
let result = try await client.vmmInfo()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .GET)
|
||||
#expect(recorded[0].uri == "/api/v1/vmm.info")
|
||||
#expect(result.version == "v40.0")
|
||||
}
|
||||
|
||||
// MARK: - vmCreate
|
||||
|
||||
@Test("vmCreate sends PUT /api/v1/vm.create with encoded body")
|
||||
func vmCreate() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let config = CloudHypervisor.VmConfig(
|
||||
cpus: .init(bootVcpus: 2, maxVcpus: 4),
|
||||
memory: .init(size: 512 * 1024 * 1024),
|
||||
payload: .init(kernel: "/boot/vmlinux"),
|
||||
console: .init(mode: .Null),
|
||||
serial: .init(mode: .Tty)
|
||||
)
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
try await client.vmCreate(config)
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.create")
|
||||
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VmConfig.self, from: recorded[0].body)
|
||||
#expect(decoded == config)
|
||||
}
|
||||
|
||||
// MARK: - vmBoot
|
||||
|
||||
@Test("vmBoot sends PUT /api/v1/vm.boot with no body")
|
||||
func vmBoot() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
try await client.vmBoot()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.boot")
|
||||
#expect(recorded[0].body.isEmpty)
|
||||
}
|
||||
|
||||
// Regression: cloud-hypervisor's HTTP parser rejects body-less PUTs
|
||||
// unless they carry an explicit `Content-Length: 0`. With the
|
||||
// AsyncHTTPClient transport, that wire shape is produced by
|
||||
// assigning `request.body = .bytes(ByteBuffer())` so AHC's
|
||||
// RequestValidation re-derives framing as `known(0)` per RFC 7230
|
||||
// §3.3.2. This test asserts the on-the-wire result rather than how
|
||||
// it's produced, so any future transport change that drops the
|
||||
// empty-body framing surfaces here.
|
||||
@Test("Body-less PUT sends Content-Length: 0 with empty body")
|
||||
func bodylessPUTSendsContentLengthZero() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(
|
||||
socketPath: URL(filePath: server.socketPath),
|
||||
eventLoopGroup: Self.group
|
||||
)
|
||||
try await client.vmBoot()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
let req = try #require(recorded.first)
|
||||
#expect(req.method == .PUT)
|
||||
#expect(req.uri == "/api/v1/vm.boot")
|
||||
#expect(req.body.isEmpty)
|
||||
#expect(req.headers["Content-Length"].first == "0")
|
||||
}
|
||||
|
||||
// MARK: - vmShutdown
|
||||
|
||||
@Test("vmShutdown sends PUT /api/v1/vm.shutdown with no body")
|
||||
func vmShutdown() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
try await client.vmShutdown()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.shutdown")
|
||||
#expect(recorded[0].body.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - vmInfo
|
||||
|
||||
@Test("vmInfo sends GET /api/v1/vm.info and decodes VmInfo")
|
||||
func vmInfo() async throws {
|
||||
let expectedConfig = CloudHypervisor.VmConfig(
|
||||
cpus: .init(bootVcpus: 1, maxVcpus: 1),
|
||||
memory: .init(size: 256 * 1024 * 1024),
|
||||
payload: .init(kernel: "/boot/vmlinux"),
|
||||
console: .init(mode: .Null),
|
||||
serial: .init(mode: .Null)
|
||||
)
|
||||
let expected = CloudHypervisor.VmInfo(config: expectedConfig, state: .Running)
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
(try? StubResponse.json(expected)) ?? StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
let result = try await client.vmInfo()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .GET)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.info")
|
||||
#expect(recorded[0].body.isEmpty)
|
||||
#expect(result == expected)
|
||||
}
|
||||
|
||||
// MARK: - vmPause
|
||||
|
||||
@Test("vmPause sends PUT /api/v1/vm.pause with no body")
|
||||
func vmPause() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
try await client.vmPause()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.pause")
|
||||
#expect(recorded[0].body.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - vmResume
|
||||
|
||||
@Test("vmResume sends PUT /api/v1/vm.resume with no body")
|
||||
func vmResume() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
try await client.vmResume()
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.resume")
|
||||
#expect(recorded[0].body.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - vmAddDisk
|
||||
|
||||
@Test("vmAddDisk sends PUT /api/v1/vm.add-disk and returns PciDeviceInfo")
|
||||
func vmAddDisk() async throws {
|
||||
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let config = CloudHypervisor.DiskConfig(path: "/tmp/disk.img", readonly: true, id: "_disk0")
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
let result = try await client.vmAddDisk(config)
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.add-disk")
|
||||
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.DiskConfig.self, from: recorded[0].body)
|
||||
#expect(decoded == config)
|
||||
#expect(result == pciInfo)
|
||||
}
|
||||
|
||||
// MARK: - vmAddFs
|
||||
|
||||
@Test("vmAddFs sends PUT /api/v1/vm.add-fs and returns PciDeviceInfo")
|
||||
func vmAddFs() async throws {
|
||||
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let config = CloudHypervisor.FsConfig(tag: "myfs", socket: "/tmp/virtiofsd.sock", id: "_fs0")
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
let result = try await client.vmAddFs(config)
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.add-fs")
|
||||
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.FsConfig.self, from: recorded[0].body)
|
||||
#expect(decoded == config)
|
||||
#expect(result == pciInfo)
|
||||
}
|
||||
|
||||
// MARK: - vmAddNet
|
||||
|
||||
@Test("vmAddNet sends PUT /api/v1/vm.add-net and returns PciDeviceInfo")
|
||||
func vmAddNet() async throws {
|
||||
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let config = CloudHypervisor.NetConfig(tap: "tap0", mac: "AA:BB:CC:DD:EE:FF", id: "_net0")
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
let result = try await client.vmAddNet(config)
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.add-net")
|
||||
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.NetConfig.self, from: recorded[0].body)
|
||||
#expect(decoded == config)
|
||||
#expect(result == pciInfo)
|
||||
}
|
||||
|
||||
// MARK: - vmAddVsock
|
||||
|
||||
@Test("vmAddVsock sends PUT /api/v1/vm.add-vsock and returns PciDeviceInfo")
|
||||
func vmAddVsock() async throws {
|
||||
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let config = CloudHypervisor.VsockConfig(cid: 3, socket: "/tmp/vsock.sock", id: "_vsock0")
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
let result = try await client.vmAddVsock(config)
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.add-vsock")
|
||||
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VsockConfig.self, from: recorded[0].body)
|
||||
#expect(decoded == config)
|
||||
#expect(result == pciInfo)
|
||||
}
|
||||
|
||||
// MARK: - vmRemoveDevice
|
||||
|
||||
@Test("vmRemoveDevice sends PUT /api/v1/vm.remove-device with id body")
|
||||
func vmRemoveDevice() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
||||
try await client.vmRemoveDevice(id: "_disk0")
|
||||
|
||||
let recorded = server.recordedRequests()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded[0].method == .PUT)
|
||||
#expect(recorded[0].uri == "/api/v1/vm.remove-device")
|
||||
|
||||
struct RemoveRequest: Decodable { let id: String }
|
||||
let decoded = try JSONDecoder().decode(RemoveRequest.self, from: recorded[0].body)
|
||||
#expect(decoded.id == "_disk0")
|
||||
}
|
||||
|
||||
// MARK: - Malformed JSON
|
||||
|
||||
@Test("Malformed JSON on 200 response throws .decoding")
|
||||
func malformedJSONThrowsDecoding() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.ok(Data("not json".utf8))
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let socketURL = URL(filePath: server.socketPath)
|
||||
let client = try CloudHypervisor.Client(socketPath: socketURL, eventLoopGroup: Self.group)
|
||||
|
||||
struct Dummy: Decodable, Sendable {}
|
||||
|
||||
do {
|
||||
let _: Dummy = try await client.get("/api/v1/vmm.info")
|
||||
Issue.record("Expected .decoding error but call succeeded")
|
||||
} catch let err as CloudHypervisor.Error {
|
||||
guard case .decoding = err else {
|
||||
Issue.record("Expected .decoding, got \(err)")
|
||||
return
|
||||
}
|
||||
// Expected path — decoding error correctly surfaced.
|
||||
} catch {
|
||||
Issue.record("Expected CloudHypervisor.Error but got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shutdown ordering
|
||||
|
||||
/// Regression: with a caller-supplied group, `Client.shutdown()` must
|
||||
/// drain the underlying HTTPClient before the caller tears the group
|
||||
/// down. Without this, AsyncHTTPClient's deferred connection-cleanup
|
||||
/// runs on the (now-dead) event loops and SwiftNIO prints
|
||||
/// "Cannot schedule tasks on an EventLoop that has already shut down".
|
||||
/// The singleton group used by the rest of this suite can't surface
|
||||
/// the bug because it never shuts down, so we spin up a dedicated
|
||||
/// group for the client here. The server stays on the singleton so
|
||||
/// only the client-side AHC channels are at risk when we shut the
|
||||
/// owned group down — otherwise the server's own pipeline cleanup
|
||||
/// would race the same group teardown and confound the test.
|
||||
@Test("Client.shutdown drains HTTPClient before a caller-owned group is torn down")
|
||||
func shutdownDrainsHTTPClientBeforeGroup() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
let clientGroup = MultiThreadedEventLoopGroup(numberOfThreads: 2)
|
||||
let client = try CloudHypervisor.Client(
|
||||
socketPath: URL(filePath: server.socketPath),
|
||||
eventLoopGroup: clientGroup
|
||||
)
|
||||
// Round-trip a real request so AHC actually opens a connection
|
||||
// and parks its post-response cleanup on `clientGroup`.
|
||||
try await client.vmmShutdown()
|
||||
|
||||
try await client.shutdown()
|
||||
// Idempotent — a second call must not throw.
|
||||
try await client.shutdown()
|
||||
|
||||
// The owned group should now be safe to tear down without NIO
|
||||
// warnings.
|
||||
try await clientGroup.shutdownGracefully()
|
||||
}
|
||||
|
||||
@Test("Client.shutdown also tears down the group when the client owns it")
|
||||
func shutdownOwnsGroup() async throws {
|
||||
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
||||
StubResponse.status(.noContent)
|
||||
}
|
||||
defer { Task { try? await server.shutdown() } }
|
||||
|
||||
// No eventLoopGroup → client owns its own.
|
||||
let client = try CloudHypervisor.Client(
|
||||
socketPath: URL(filePath: server.socketPath)
|
||||
)
|
||||
try await client.vmmShutdown()
|
||||
try await client.shutdown()
|
||||
// Idempotent.
|
||||
try await client.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import NIOHTTP1
|
||||
import Testing
|
||||
|
||||
@testable import CloudHypervisor
|
||||
|
||||
@Suite("CloudHypervisor.Error")
|
||||
struct ErrorsTests {
|
||||
@Test("http case carries status and body")
|
||||
func httpCase() {
|
||||
let err = CloudHypervisor.Error.http(status: .badRequest, body: Data("nope".utf8))
|
||||
guard case .http(let status, let body) = err else {
|
||||
Issue.record("expected .http")
|
||||
return
|
||||
}
|
||||
#expect(status == .badRequest)
|
||||
#expect(String(data: body, encoding: .utf8) == "nope")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import NIOConcurrencyHelpers
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
import NIOPosix
|
||||
|
||||
// MARK: - StubRequest / StubResponse
|
||||
|
||||
/// An inbound HTTP request captured by the stub server.
|
||||
struct StubRequest: Sendable {
|
||||
let method: HTTPMethod
|
||||
let uri: String
|
||||
let body: Data
|
||||
let headers: HTTPHeaders
|
||||
}
|
||||
|
||||
/// A canned HTTP response produced by the stub server.
|
||||
struct StubResponse: Sendable {
|
||||
let status: HTTPResponseStatus
|
||||
let body: Data
|
||||
let headers: HTTPHeaders
|
||||
|
||||
static func ok(_ body: Data = .init()) -> StubResponse {
|
||||
StubResponse(status: .ok, body: body, headers: [:])
|
||||
}
|
||||
|
||||
static func json<T: Encodable>(_ value: T) throws -> StubResponse {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
var headers = HTTPHeaders()
|
||||
headers.add(name: "Content-Type", value: "application/json")
|
||||
return StubResponse(status: .ok, body: data, headers: headers)
|
||||
}
|
||||
|
||||
static func status(_ status: HTTPResponseStatus, body: Data = .init()) -> StubResponse {
|
||||
StubResponse(status: status, body: body, headers: [:])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StubHTTPServer
|
||||
|
||||
/// An in-process HTTP/1.1 server bound to a Unix Domain Socket, used in tests.
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// let server = try await StubHTTPServer(eventLoopGroup: group) { req in
|
||||
/// return StubResponse.ok(Data("{}".utf8))
|
||||
/// }
|
||||
/// defer { Task { try? await server.shutdown() } }
|
||||
/// ```
|
||||
final class StubHTTPServer: Sendable {
|
||||
/// The path to the Unix Domain Socket this server is bound to.
|
||||
let socketPath: String
|
||||
|
||||
private let channel: Channel
|
||||
/// Recorded requests, protected by a lock so the test thread can read safely.
|
||||
private let requests: NIOLockedValueBox<[StubRequest]>
|
||||
|
||||
init(
|
||||
eventLoopGroup: any EventLoopGroup,
|
||||
handler: @escaping @Sendable (StubRequest) -> StubResponse
|
||||
) async throws {
|
||||
let sockPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ch-stub-\(UUID().uuidString).sock")
|
||||
.path
|
||||
|
||||
let requestsBox = NIOLockedValueBox<[StubRequest]>([])
|
||||
|
||||
let bootstrap = ServerBootstrap(group: eventLoopGroup)
|
||||
.serverChannelOption(.backlog, value: 256)
|
||||
.serverChannelOption(.socketOption(.so_reuseaddr), value: 1)
|
||||
.childChannelInitializer { channel in
|
||||
channel.eventLoop.makeCompletedFuture {
|
||||
try channel.pipeline.syncOperations.configureHTTPServerPipeline(
|
||||
withPipeliningAssistance: false
|
||||
)
|
||||
try channel.pipeline.syncOperations.addHandler(
|
||||
StubRequestHandler(userHandler: handler, requests: requestsBox)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let boundChannel =
|
||||
try await bootstrap
|
||||
.bind(unixDomainSocketPath: sockPath, cleanupExistingSocketFile: true)
|
||||
.get()
|
||||
|
||||
self.socketPath = sockPath
|
||||
self.channel = boundChannel
|
||||
self.requests = requestsBox
|
||||
}
|
||||
|
||||
/// Stop accepting connections and close the listening socket.
|
||||
func shutdown() async throws {
|
||||
try await channel.close().get()
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
|
||||
/// Returns all requests recorded so far.
|
||||
func recordedRequests() -> [StubRequest] {
|
||||
requests.withLockedValue { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StubRequestHandler
|
||||
|
||||
/// Handles a single inbound HTTP/1.1 request, invokes the user handler, and
|
||||
/// writes the stub response.
|
||||
///
|
||||
/// All ChannelHandler callbacks run on the channel's event loop, so the mutable
|
||||
/// inbound-state fields need no external synchronisation. The shared `requests`
|
||||
/// box is still locked because the test thread reads it from outside the loop.
|
||||
private final class StubRequestHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||
typealias InboundIn = HTTPServerRequestPart
|
||||
typealias OutboundOut = HTTPServerResponsePart
|
||||
|
||||
private let userHandler: @Sendable (StubRequest) -> StubResponse
|
||||
private let requests: NIOLockedValueBox<[StubRequest]>
|
||||
|
||||
// Mutable inbound state — only touched on the event loop.
|
||||
private var pendingMethod: HTTPMethod?
|
||||
private var pendingURI: String?
|
||||
private var pendingHeaders: HTTPHeaders = [:]
|
||||
private var pendingBody: [UInt8] = []
|
||||
|
||||
init(
|
||||
userHandler: @escaping @Sendable (StubRequest) -> StubResponse,
|
||||
requests: NIOLockedValueBox<[StubRequest]>
|
||||
) {
|
||||
self.userHandler = userHandler
|
||||
self.requests = requests
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
switch unwrapInboundIn(data) {
|
||||
case .head(let head):
|
||||
pendingMethod = head.method
|
||||
pendingURI = head.uri
|
||||
pendingHeaders = head.headers
|
||||
pendingBody = []
|
||||
case .body(var buf):
|
||||
if let bytes = buf.readBytes(length: buf.readableBytes) {
|
||||
pendingBody.append(contentsOf: bytes)
|
||||
}
|
||||
case .end:
|
||||
guard let method = pendingMethod, let uri = pendingURI else {
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
let request = StubRequest(
|
||||
method: method,
|
||||
uri: uri,
|
||||
body: Data(pendingBody),
|
||||
headers: pendingHeaders
|
||||
)
|
||||
requests.withLockedValue { $0.append(request) }
|
||||
let stubResp = userHandler(request)
|
||||
writeResponse(context: context, response: stubResp)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeResponse(context: ChannelHandlerContext, response: StubResponse) {
|
||||
var respHeaders = response.headers
|
||||
respHeaders.replaceOrAdd(name: "Content-Length", value: "\(response.body.count)")
|
||||
respHeaders.replaceOrAdd(name: "Connection", value: "close")
|
||||
|
||||
let head = HTTPResponseHead(version: .http1_1, status: response.status, headers: respHeaders)
|
||||
context.write(wrapOutboundOut(.head(head)), promise: nil)
|
||||
|
||||
if !response.body.isEmpty {
|
||||
var buf = context.channel.allocator.buffer(capacity: response.body.count)
|
||||
buf.writeBytes(response.body)
|
||||
context.write(wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil)
|
||||
}
|
||||
|
||||
// Use NIOLoopBound to safely capture `context` in a @Sendable closure.
|
||||
// The bound asserts event-loop access; the close runs on the same loop
|
||||
// as the flush completion, which is correct.
|
||||
let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop)
|
||||
context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in
|
||||
boundContext.value.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: any Error) {
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import CloudHypervisor
|
||||
|
||||
@Suite("CloudHypervisor types")
|
||||
struct TypesTests {
|
||||
@Test("VmConfig round-trips through JSON")
|
||||
func vmConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.VmConfig(
|
||||
cpus: CloudHypervisor.CpusConfig(bootVcpus: 2, maxVcpus: 2),
|
||||
memory: CloudHypervisor.MemoryConfig(size: UInt64(1) << 30),
|
||||
payload: CloudHypervisor.PayloadConfig(
|
||||
kernel: "/path/to/vmlinux",
|
||||
cmdline: "init=/sbin/vminitd ro"
|
||||
),
|
||||
disks: nil,
|
||||
net: nil,
|
||||
fs: nil,
|
||||
vsock: nil,
|
||||
console: CloudHypervisor.ConsoleConfig(mode: .Null),
|
||||
serial: CloudHypervisor.ConsoleConfig(mode: .Null)
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VmConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
|
||||
// Verify snake_case keys are emitted.
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"boot_vcpus\""))
|
||||
#expect(jsonString.contains("\"max_vcpus\""))
|
||||
}
|
||||
|
||||
@Test("CpusConfig round-trips through JSON")
|
||||
func cpusConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.CpusConfig(bootVcpus: 4, maxVcpus: 8)
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.CpusConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
}
|
||||
|
||||
@Test("MemoryConfig round-trips through JSON")
|
||||
func memoryConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.MemoryConfig(size: UInt64(2) << 30, hotplugSize: UInt64(1) << 30, mergeable: true)
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.MemoryConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
}
|
||||
|
||||
@Test("MemoryConfig omits nil optional fields from JSON")
|
||||
func memoryConfigNilOmission() throws {
|
||||
let cfg = CloudHypervisor.MemoryConfig(size: UInt64(1) << 30)
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(!jsonString.contains("\"hotplug_size\""))
|
||||
#expect(!jsonString.contains("\"mergeable\""))
|
||||
}
|
||||
|
||||
@Test("PayloadConfig round-trips through JSON")
|
||||
func payloadConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.PayloadConfig(
|
||||
kernel: "/boot/vmlinux",
|
||||
initramfs: "/boot/initrd",
|
||||
cmdline: "console=ttyS0"
|
||||
)
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.PayloadConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
}
|
||||
|
||||
@Test("ConsoleConfig round-trips through JSON with capitalized mode strings")
|
||||
func consoleConfigRoundTrip() throws {
|
||||
for mode in [
|
||||
CloudHypervisor.ConsoleConfig.Mode.Off,
|
||||
.Pty,
|
||||
.Tty,
|
||||
.File,
|
||||
.Socket,
|
||||
.Null,
|
||||
] {
|
||||
let cfg = CloudHypervisor.ConsoleConfig(mode: mode)
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.ConsoleConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
// CH uses capitalized strings: "Off", "Pty", etc.
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"" + mode.rawValue + "\""))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("DiskConfig round-trips through JSON")
|
||||
func diskConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.DiskConfig(
|
||||
path: "/var/lib/disk.raw",
|
||||
readonly: true,
|
||||
direct: false,
|
||||
iommu: nil,
|
||||
id: "disk0",
|
||||
pciSegment: 0
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.DiskConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
|
||||
// Verify snake_case key for pci_segment.
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"pci_segment\""))
|
||||
}
|
||||
|
||||
@Test("DiskConfig omits nil optional fields from JSON")
|
||||
func diskConfigNilOmission() throws {
|
||||
let cfg = CloudHypervisor.DiskConfig(path: "/var/lib/disk.raw")
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(!jsonString.contains("\"readonly\""))
|
||||
#expect(!jsonString.contains("\"direct\""))
|
||||
#expect(!jsonString.contains("\"iommu\""))
|
||||
#expect(!jsonString.contains("\"id\""))
|
||||
#expect(!jsonString.contains("\"pci_segment\""))
|
||||
}
|
||||
|
||||
@Test("NetConfig round-trips through JSON")
|
||||
func netConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.NetConfig(
|
||||
tap: "tap0",
|
||||
ip: "192.168.0.1",
|
||||
mask: "255.255.255.0",
|
||||
mac: "AA:BB:CC:DD:EE:FF",
|
||||
mtu: 1500,
|
||||
numQueues: 2,
|
||||
queueSize: 256,
|
||||
id: "net0"
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.NetConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
|
||||
// Verify snake_case keys.
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"num_queues\""))
|
||||
#expect(jsonString.contains("\"queue_size\""))
|
||||
}
|
||||
|
||||
@Test("FsConfig round-trips through JSON")
|
||||
func fsConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.FsConfig(
|
||||
tag: "virtiofs0",
|
||||
socket: "/run/virtiofs.sock",
|
||||
numQueues: 1,
|
||||
queueSize: 1024,
|
||||
id: "fs0",
|
||||
pciSegment: nil
|
||||
)
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.FsConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
}
|
||||
|
||||
@Test("VsockConfig round-trips through JSON")
|
||||
func vsockConfigRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.VsockConfig(
|
||||
cid: 3,
|
||||
socket: "/run/vsock.sock",
|
||||
iommu: false,
|
||||
id: "vsock0"
|
||||
)
|
||||
let data = try JSONEncoder().encode(cfg)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VsockConfig.self, from: data)
|
||||
#expect(decoded == cfg)
|
||||
}
|
||||
|
||||
@Test("PciDeviceInfo round-trips through JSON")
|
||||
func pciDeviceInfoRoundTrip() throws {
|
||||
let info = CloudHypervisor.PciDeviceInfo(id: "disk0", bdf: "0000:00:03.0")
|
||||
let data = try JSONEncoder().encode(info)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.PciDeviceInfo.self, from: data)
|
||||
#expect(decoded == info)
|
||||
}
|
||||
|
||||
// MARK: - VmInfo / VmState
|
||||
|
||||
@Test("VmState round-trips through JSON with CH literal strings")
|
||||
func vmStateRoundTrip() throws {
|
||||
for state in [
|
||||
CloudHypervisor.VmState.Created,
|
||||
.Running,
|
||||
.Shutdown,
|
||||
.Paused,
|
||||
.BreakPoint,
|
||||
] {
|
||||
let data = try JSONEncoder().encode(state)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VmState.self, from: data)
|
||||
#expect(decoded == state)
|
||||
// CH uses the capitalized raw string literals exactly.
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"" + state.rawValue + "\""))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("VmInfo round-trips through JSON")
|
||||
func vmInfoRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.VmConfig(
|
||||
cpus: CloudHypervisor.CpusConfig(bootVcpus: 2, maxVcpus: 2),
|
||||
memory: CloudHypervisor.MemoryConfig(size: UInt64(1) << 30),
|
||||
payload: CloudHypervisor.PayloadConfig(kernel: "/boot/vmlinux"),
|
||||
console: CloudHypervisor.ConsoleConfig(mode: .Null),
|
||||
serial: CloudHypervisor.ConsoleConfig(mode: .Null)
|
||||
)
|
||||
let info = CloudHypervisor.VmInfo(
|
||||
config: cfg,
|
||||
state: .Running,
|
||||
memoryActualSize: 1_073_741_824
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(info)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VmInfo.self, from: data)
|
||||
#expect(decoded == info)
|
||||
|
||||
// Verify snake_case key is emitted.
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"memory_actual_size\""))
|
||||
}
|
||||
|
||||
@Test("VmInfo omits nil optional fields from JSON")
|
||||
func vmInfoNilOmission() throws {
|
||||
let cfg = CloudHypervisor.VmConfig(
|
||||
cpus: CloudHypervisor.CpusConfig(bootVcpus: 1, maxVcpus: 1),
|
||||
memory: CloudHypervisor.MemoryConfig(size: UInt64(512) << 20),
|
||||
payload: CloudHypervisor.PayloadConfig(kernel: "/boot/vmlinux"),
|
||||
console: CloudHypervisor.ConsoleConfig(mode: .Off),
|
||||
serial: CloudHypervisor.ConsoleConfig(mode: .Off)
|
||||
)
|
||||
let info = CloudHypervisor.VmInfo(config: cfg, state: .Created)
|
||||
let data = try JSONEncoder().encode(info)
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(!jsonString.contains("\"memory_actual_size\""))
|
||||
}
|
||||
|
||||
// MARK: - VmmPingResponse
|
||||
|
||||
@Test("VmmPingResponse round-trips through JSON")
|
||||
func vmmPingResponseRoundTrip() throws {
|
||||
let ping = CloudHypervisor.VmmPingResponse(
|
||||
version: "v40.0",
|
||||
pid: 12345,
|
||||
features: ["acpi", "kvm"],
|
||||
buildVersion: "abc123"
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(ping)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VmmPingResponse.self, from: data)
|
||||
#expect(decoded == ping)
|
||||
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"build_version\""))
|
||||
}
|
||||
|
||||
@Test("VmmPingResponse omits nil optional fields from JSON")
|
||||
func vmmPingResponseNilOmission() throws {
|
||||
let ping = CloudHypervisor.VmmPingResponse(version: "v40.0")
|
||||
let data = try JSONEncoder().encode(ping)
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(!jsonString.contains("\"pid\""))
|
||||
#expect(!jsonString.contains("\"features\""))
|
||||
#expect(!jsonString.contains("\"build_version\""))
|
||||
}
|
||||
|
||||
// MARK: - VmmInfo
|
||||
|
||||
@Test("VmmInfo round-trips through JSON")
|
||||
func vmmInfoRoundTrip() throws {
|
||||
let cfg = CloudHypervisor.VmConfig(
|
||||
cpus: CloudHypervisor.CpusConfig(bootVcpus: 2, maxVcpus: 2),
|
||||
memory: CloudHypervisor.MemoryConfig(size: UInt64(1) << 30),
|
||||
payload: CloudHypervisor.PayloadConfig(kernel: "/boot/vmlinux"),
|
||||
console: CloudHypervisor.ConsoleConfig(mode: .Null),
|
||||
serial: CloudHypervisor.ConsoleConfig(mode: .Null)
|
||||
)
|
||||
let vmmInfo = CloudHypervisor.VmmInfo(
|
||||
version: "v40.0",
|
||||
pid: 99,
|
||||
buildVersion: "deadbeef",
|
||||
config: cfg
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(vmmInfo)
|
||||
let decoded = try JSONDecoder().decode(CloudHypervisor.VmmInfo.self, from: data)
|
||||
#expect(decoded == vmmInfo)
|
||||
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(jsonString.contains("\"build_version\""))
|
||||
}
|
||||
|
||||
@Test("VmmInfo omits nil optional fields from JSON")
|
||||
func vmmInfoNilOmission() throws {
|
||||
let vmmInfo = CloudHypervisor.VmmInfo(version: "v40.0")
|
||||
let data = try JSONEncoder().encode(vmmInfo)
|
||||
let jsonString = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(!jsonString.contains("\"pid\""))
|
||||
#expect(!jsonString.contains("\"build_version\""))
|
||||
#expect(!jsonString.contains("\"config\""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user