Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
@@ -0,0 +1,443 @@
import Foundation
import Testing
#if canImport(FoundationNetworking)
// `URLRequest` lives in FoundationNetworking on Linux, as it does in RunnerCore.
import FoundationNetworking
#endif
@testable import RunnerCore
/// Ways a fixture can be wrong about its own inputs.
private enum FixtureFailure: Error {
case unexpectedRequestCount(Int)
case unusableURL
}
/// A canned ``HTTPTransport`` that records what it was asked to send.
///
/// An actor rather than a locked class: ``HTTPTransport/send(_:)`` is `async`,
/// so actor isolation satisfies the requirement directly and the recorded
/// requests need no lock of their own. No `URLProtocol`, no loopback server —
/// the seam is the protocol.
private actor MockTransport: HTTPTransport {
/// Every request handed to ``send(_:)``, in order.
private(set) var requests: [URLRequest] = []
private let handler: @Sendable (URLRequest) -> (Data, Int)
/// - Parameter handler: Produces the canned `(body, status)` for a request.
init(handler: @escaping @Sendable (URLRequest) -> (Data, Int)) {
self.handler = handler
}
/// Convenience: always answer with one status and body.
init(status: Int, body: String = "") {
self.handler = { (_: URLRequest) in (Data(body.utf8), status) }
}
func send(_ request: URLRequest) async throws -> (Data, Int) {
requests.append(request)
return handler(request)
}
/// The single request that was sent, or a failure if the count differs.
func onlyRequest() throws -> URLRequest {
guard requests.count == 1, let only = requests.first else {
throw FixtureFailure.unexpectedRequestCount(requests.count)
}
return only
}
}
/// Tests for ``GiteaClient`` request shaping and error mapping, driven through a
/// fake ``HTTPTransport``.
@Suite("GiteaClient")
struct GiteaClientTests {
private let base = URL(string: "https://gitea.example.com")!
private func components(_ request: URLRequest) throws -> URLComponents {
guard let url = request.url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { throw FixtureFailure.unusableURL }
return components
}
private func queryValue(_ request: URLRequest, _ name: String) throws -> String? {
try components(request).queryItems?.first(where: { $0.name == name })?.value
}
// MARK: - Construction
@Test("a client retains the base URL it was constructed with")
func clientRetainsBaseURL() throws {
let url = try #require(URL(string: "https://gitea.example.com"))
let client = GiteaClient(baseURL: url, token: "t")
#expect(client.baseURL == url)
}
// MARK: - Headers
@Test("every request carries Gitea's token auth and a JSON Accept header")
func requestHeaders() throws {
let client = GiteaClient(baseURL: base, token: "s3cret")
let request = try client.makeRequest(method: "GET", path: "/api/v1/admin/actions/runners")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token s3cret")
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
// No body, so no Content-Type.
#expect(request.value(forHTTPHeaderField: "Content-Type") == nil)
}
@Test("a request with a body declares JSON content")
func requestWithBody() throws {
let client = GiteaClient(baseURL: base, token: "t")
let request = try client.makeRequest(
method: "POST", path: "/api/v1/x", body: Data(#"{"a":1}"#.utf8))
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
#expect(request.httpBody == Data(#"{"a":1}"#.utf8))
}
@Test("a trailing slash on the base URL does not double up")
func baseURLTrailingSlash() throws {
let client = GiteaClient(baseURL: try #require(URL(string: "https://gitea.example.com/")), token: "t")
let request = try client.makeRequest(method: "GET", path: "/api/v1/version")
#expect(request.url?.absoluteString == "https://gitea.example.com/api/v1/version")
}
@Test("an instance served under a subpath keeps that subpath")
func baseURLWithSubpath() throws {
// `URL(string:relativeTo:)` would drop "/gitea" here; string joining does not.
let client = GiteaClient(baseURL: try #require(URL(string: "https://example.com/gitea")), token: "t")
let request = try client.makeRequest(method: "GET", path: "/api/v1/version")
#expect(request.url?.absoluteString == "https://example.com/gitea/api/v1/version")
}
// MARK: - listQueuedJobs
@Test("listQueuedJobs GETs the admin jobs endpoint with status=queued")
func listQueuedJobsRequestShape() async throws {
let body = """
{"total_count": 1, "jobs": [
{"id": 4711, "run_id": 12, "name": "build", "labels": ["macos-arm64"],
"status": "queued", "created_at": "2026-08-07T09:15:04Z"}
]}
"""
let transport = MockTransport(status: 200, body: body)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let jobs = try await client.listQueuedJobs(limit: 25)
#expect(jobs.map(\.id) == [4711])
#expect(jobs.first?.labels == ["macos-arm64"])
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "GET")
#expect(try components(request).path == "/api/v1/admin/actions/jobs")
// "queued" and never "waiting": the latter means blocked on a dependency.
#expect(try queryValue(request, "status") == "queued")
#expect(try queryValue(request, "limit") == "25")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
}
@Test("listQueuedJobs defaults to a limit of 50")
func listQueuedJobsDefaultLimit() async throws {
let transport = MockTransport(status: 200, body: #"{"total_count": 0, "jobs": []}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
#expect(try await client.listQueuedJobs().isEmpty)
#expect(try await queryValue(transport.onlyRequest(), "limit") == "50")
}
@Test("listQueuedJobs never asks for a limit below 1")
func listQueuedJobsClampsLimit() async throws {
let transport = MockTransport(status: 200, body: #"{"jobs": []}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
_ = try await client.listQueuedJobs(limit: 0)
#expect(try await queryValue(transport.onlyRequest(), "limit") == "1")
}
@Test("listQueuedJobs surfaces a 401 as a gitea error")
func listQueuedJobsUnauthorized() async throws {
let transport = MockTransport(status: 401, body: #"{"message": "token is invalid", "url": "..."}"#)
let client = GiteaClient(baseURL: base, token: "bad", transport: transport)
await #expect(throws: CoreError.self) {
_ = try await client.listQueuedJobs()
}
do {
_ = try await client.listQueuedJobs()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 401)
// Gitea's own `message` field, not the raw envelope.
#expect(message == "token is invalid")
}
}
@Test("a 500 with a non-JSON body reports a body excerpt")
func serverErrorReportsExcerpt() async throws {
let transport = MockTransport(status: 500, body: "<html>Internal Server Error</html>")
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
do {
_ = try await client.listQueuedJobs()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 500)
#expect(message.contains("Internal Server Error"))
}
}
@Test("a 2xx with an undecodable body is an error, not a silent empty list")
func undecodableBodyIsAnError() async throws {
let transport = MockTransport(status: 200, body: "not json at all")
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
await #expect(throws: CoreError.self) {
_ = try await client.listQueuedJobs()
}
}
// MARK: - listRunners
@Test("listRunners GETs the admin runners endpoint and decodes object labels")
func listRunnersRequestShape() async throws {
let body = """
{"total_count": 1, "runners": [
{"id": 9, "name": "macos-vm-abc", "status": "online", "busy": false,
"ephemeral": true, "labels": [{"id": 3, "name": "macos-arm64", "type": "custom"}]}
]}
"""
let transport = MockTransport(status: 200, body: body)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let runners = try await client.listRunners()
#expect(runners.count == 1)
#expect(runners.first?.labels == ["macos-arm64"])
#expect(runners.first?.isEphemeral == true)
#expect(runners.first?.isBusy == false)
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "GET")
#expect(try components(request).path == "/api/v1/admin/actions/runners")
// Paginated: `total_count` says there is nothing past this page, so one
// request is all it takes.
let query = try components(request).queryItems ?? []
#expect(query.contains(URLQueryItem(name: "page", value: "1")))
#expect(query.contains(URLQueryItem(name: "limit", value: "50")))
}
@Test("listRunners walks every page rather than returning only the first")
func listRunnersPaginates() async throws {
// Gitea clamps `limit` to its own maximum, so a page shorter than the
// one asked for does not mean the walk is over — only `total_count` does.
let transport = MockTransport { request in
let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "page" }?.value ?? "1"
let id = page == "1" ? 1 : 2
let body = """
{"total_count": 2, "runners": [
{"id": \(id), "name": "macos-vm-\(id)", "status": "online", "busy": false,
"ephemeral": true, "labels": ["macos-arm64"]}
]}
"""
return (Data(body.utf8), 200)
}
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let runners = try await client.listRunners()
#expect(runners.map(\.id) == [1, 2])
await #expect(transport.requests.count == 2)
}
@Test("listRunners stops on an empty page when the server omits total_count")
func listRunnersStopsOnEmptyPage() async throws {
let transport = MockTransport { request in
let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "page" }?.value ?? "1"
let body = page == "1"
? #"{"runners": [{"id": 1, "name": "macos-vm-1", "ephemeral": true, "labels": []}]}"#
: #"{"runners": []}"#
return (Data(body.utf8), 200)
}
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let runners = try await client.listRunners()
#expect(runners.map(\.id) == [1])
await #expect(transport.requests.count == 2)
}
@Test("listRunners surfaces a 403 as a gitea error")
func listRunnersForbidden() async throws {
let transport = MockTransport(status: 403, body: #"{"message": "not an admin"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
do {
_ = try await client.listRunners()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 403)
#expect(message == "not an admin")
}
}
// MARK: - deleteRunner
@Test("deleteRunner DELETEs the runner by id and accepts 204")
func deleteRunnerRequestShape() async throws {
let transport = MockTransport(status: 204)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
try await client.deleteRunner(id: 9)
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "DELETE")
#expect(try components(request).path == "/api/v1/admin/actions/runners/9")
}
@Test("deleteRunner tolerates a 404")
func deleteRunnerTolerates404() async throws {
// The goal is only that the row be gone. We race Gitea's own midnight
// sweep and `--ephemeral` auto-deregistration, so "already absent" is
// success, not a failure worth logging every five minutes.
let transport = MockTransport(status: 404, body: #"{"message": "runner not found"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
try await client.deleteRunner(id: 9)
}
@Test("deleteRunner accepts a 200 as well as a 204")
func deleteRunnerTolerates200() async throws {
let client = GiteaClient(baseURL: base, token: "t", transport: MockTransport(status: 200))
try await client.deleteRunner(id: 1)
}
@Test("deleteRunner still fails on a 500")
func deleteRunnerFailsOn500() async throws {
let transport = MockTransport(status: 500, body: #"{"message": "boom"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
do {
try await client.deleteRunner(id: 9)
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 500)
#expect(message == "boom")
}
}
@Test("deleteRunner rejects a 401 rather than treating it as done")
func deleteRunnerFailsOn401() async throws {
let client = GiteaClient(
baseURL: base, token: "t", transport: MockTransport(status: 401, body: "unauthorized"))
await #expect(throws: CoreError.self) {
try await client.deleteRunner(id: 9)
}
}
// MARK: - getRegistrationToken
@Test("getRegistrationToken POSTs and returns the token")
func registrationTokenRequestShape() async throws {
let transport = MockTransport(status: 200, body: #"{"token": "AABBCC00112233"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
#expect(try await client.getRegistrationToken() == "AABBCC00112233")
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "POST")
#expect(try components(request).path == "/api/v1/admin/actions/runners/registration-token")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
}
@Test("getRegistrationToken trims surrounding whitespace")
func registrationTokenIsTrimmed() async throws {
let transport = MockTransport(status: 200, body: "{\"token\": \" AABB \\n\"}")
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
#expect(try await client.getRegistrationToken() == "AABB")
}
@Test("getRegistrationToken rejects an empty token")
func registrationTokenRejectsEmpty() async throws {
// An empty token would be written into the guest and fail at
// `gitea-runner register`, tens of seconds and one VM boot later.
let client = GiteaClient(
baseURL: base, token: "t", transport: MockTransport(status: 200, body: #"{"token": ""}"#))
await #expect(throws: CoreError.self) {
_ = try await client.getRegistrationToken()
}
}
@Test("getRegistrationToken surfaces a 500")
func registrationTokenServerError() async throws {
let client = GiteaClient(
baseURL: base, token: "t", transport: MockTransport(status: 500, body: #"{"message": "nope"}"#))
do {
_ = try await client.getRegistrationToken()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, _) {
#expect(status == 500)
}
}
// MARK: - ping
@Test("ping probes an admin endpoint, so a non-admin token fails")
func pingUsesAnAdminEndpoint() async throws {
let transport = MockTransport(status: 200, body: #"{"total_count": 0, "runners": []}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
try await client.ping()
let request = try await transport.onlyRequest()
// Deliberately not /api/v1/version, which most instances serve
// anonymously and would therefore pass with a bad token.
#expect(try components(request).path == "/api/v1/admin/actions/runners")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
}
@Test("ping fails when the token is rejected")
func pingFailsOnBadToken() async throws {
let client = GiteaClient(
baseURL: base, token: "bad",
transport: MockTransport(status: 401, body: #"{"message": "token is invalid"}"#))
do {
try await client.ping()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, _) {
#expect(status == 401)
}
}
// MARK: - Transport-level failures
@Test("a transport error propagates unchanged")
func transportErrorPropagates() async throws {
// A dropped connection is not an API error; the poll loop logs it and
// retries on the next tick without touching any state. It must not be
// laundered into a `CoreError.gitea` with a made-up status.
let client = GiteaClient(baseURL: base, token: "t", transport: ThrowingTransport())
await #expect(throws: ThrowingTransport.Offline.self) {
_ = try await client.listQueuedJobs()
}
}
}
/// A transport that always fails, standing in for an unreachable instance.
private struct ThrowingTransport: HTTPTransport {
struct Offline: Error {}
func send(_ request: URLRequest) async throws -> (Data, Int) {
throw Offline()
}
}