Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RunnerCore
|
||||
|
||||
/// Tests for ``RunnerConfig`` decoding, normalization, validation, and token
|
||||
/// resolution.
|
||||
@Suite("RunnerConfig")
|
||||
struct ConfigTests {
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A configuration that passes ``RunnerConfig/validated()`` unmodified, so
|
||||
/// each test can break exactly one thing.
|
||||
private func validConfig() -> RunnerConfig {
|
||||
RunnerConfig(
|
||||
gitea: .init(
|
||||
instanceURL: URL(string: "https://gitea.example.com")!,
|
||||
adminToken: "abc123",
|
||||
registrationToken: "REG123"))
|
||||
}
|
||||
|
||||
/// Writes `contents` to a unique file under a fresh temporary directory and
|
||||
/// returns its path. The directory is left for the OS to reap; these are a
|
||||
/// handful of bytes per test.
|
||||
private func temporaryFile(named name: String = "token", contents: String) throws -> String {
|
||||
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("gmr-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let file = dir.appendingPathComponent(name)
|
||||
try Data(contents.utf8).write(to: file)
|
||||
return file.path
|
||||
}
|
||||
|
||||
/// Runs `body`, requiring it to throw ``CoreError/configInvalid(_:)``, and
|
||||
/// returns the detail message so a test can assert *which* rule fired.
|
||||
@discardableResult
|
||||
private func configInvalidDetail(
|
||||
_ body: () throws -> Void,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) -> String {
|
||||
do {
|
||||
try body()
|
||||
Issue.record("expected CoreError.configInvalid", sourceLocation: sourceLocation)
|
||||
return ""
|
||||
} catch let CoreError.configInvalid(detail) {
|
||||
return detail
|
||||
} catch {
|
||||
Issue.record("expected CoreError.configInvalid, got \(error)", sourceLocation: sourceLocation)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
@Test("the default configuration carries every documented default")
|
||||
func defaultsArePresent() {
|
||||
let c = RunnerConfig.default
|
||||
|
||||
#expect(c.runner.labels == ["macos-arm64"])
|
||||
#expect(c.runner.namePrefix == "macos-vm-")
|
||||
#expect(c.runner.version == "3.0.2")
|
||||
#expect(c.scheduler.maxConcurrentVMs == 2)
|
||||
#expect(c.scheduler.pollIntervalSeconds == 5)
|
||||
#expect(c.scheduler.reconcileIntervalSeconds == 300)
|
||||
#expect(c.scheduler.jobTimeoutMinutes == 120)
|
||||
#expect(c.scheduler.bootTimeoutSeconds == 300)
|
||||
#expect(c.guest.username == "admin")
|
||||
#expect(c.guest.cpuCount == 4)
|
||||
#expect(c.guest.memoryGB == 8)
|
||||
#expect(c.guest.diskGB == 64)
|
||||
#expect(c.storage.minFreeDiskGB == 20)
|
||||
// No token of either kind: `config init` writes a template an operator
|
||||
// must still fill in, and `validated()` says so rather than starting.
|
||||
#expect(c.gitea.adminToken == nil)
|
||||
#expect(c.gitea.adminTokenFile == nil)
|
||||
}
|
||||
|
||||
@Test("the default download URL substitutes the configured version")
|
||||
func downloadURLSubstitutesVersion() throws {
|
||||
var section = RunnerConfig.RunnerSection()
|
||||
section.version = "3.1.0"
|
||||
let url = try section.resolvedDownloadURL
|
||||
#expect(url.absoluteString.contains("v3.1.0/"))
|
||||
#expect(url.absoluteString.hasSuffix("gitea-runner-3.1.0-darwin-arm64"))
|
||||
#expect(!url.absoluteString.contains("{version}"))
|
||||
}
|
||||
|
||||
@Test("a download URL template with no scheme is rejected")
|
||||
func downloadURLWithoutSchemeIsRejected() {
|
||||
var section = RunnerConfig.RunnerSection()
|
||||
section.runnerDownloadURL = "gitea.com/runner-{version}"
|
||||
configInvalidDetail { _ = try section.resolvedDownloadURL }
|
||||
}
|
||||
|
||||
@Test("the default config path lives under the user's home directory")
|
||||
func defaultPathIsExpanded() {
|
||||
#expect(!RunnerConfig.defaultPath.hasPrefix("~"))
|
||||
#expect(RunnerConfig.defaultPath.hasSuffix("/.config/gitea-macos-runner/config.json"))
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
@Test("a minimal config decodes with every other section defaulted")
|
||||
func minimalConfigDecodes() throws {
|
||||
let json = """
|
||||
{"gitea": {"instanceURL": "https://gitea.example.com",
|
||||
"adminToken": "abc", "registrationToken": "reg"}}
|
||||
"""
|
||||
let c = try JSONDecoder().decode(RunnerConfig.self, from: Data(json.utf8))
|
||||
|
||||
#expect(c.gitea.instanceURL.absoluteString == "https://gitea.example.com")
|
||||
#expect(c.runner.labels == ["macos-arm64"])
|
||||
#expect(c.scheduler.pollIntervalSeconds == 5)
|
||||
#expect(c.guest.username == "admin")
|
||||
#expect(c.gitea.fetchRegistrationTokenViaAPI == false)
|
||||
}
|
||||
|
||||
@Test("a partial section keeps defaults for the keys it omits")
|
||||
func partialSectionKeepsDefaults() throws {
|
||||
let json = """
|
||||
{"gitea": {"instanceURL": "https://g.example.com", "adminToken": "a", "registrationToken": "r"},
|
||||
"guest": {"cpuCount": 8}}
|
||||
"""
|
||||
let c = try JSONDecoder().decode(RunnerConfig.self, from: Data(json.utf8))
|
||||
#expect(c.guest.cpuCount == 8)
|
||||
#expect(c.guest.memoryGB == 8) // untouched default
|
||||
#expect(c.guest.username == "admin")
|
||||
}
|
||||
|
||||
@Test("a config with no gitea section is rejected by name")
|
||||
func missingGiteaSectionIsReported() throws {
|
||||
let path = try temporaryFile(named: "config.json", contents: #"{"guest": {"cpuCount": 2}}"#)
|
||||
let detail = configInvalidDetail { _ = try RunnerConfig.load(from: path) }
|
||||
#expect(detail.contains("gitea"))
|
||||
}
|
||||
|
||||
@Test("loading a file that is not JSON reports it as malformed, not missing")
|
||||
func malformedJSONIsReported() throws {
|
||||
let path = try temporaryFile(named: "config.json", contents: "not json {")
|
||||
let detail = configInvalidDetail { _ = try RunnerConfig.load(from: path) }
|
||||
#expect(detail.contains(path))
|
||||
#expect(!detail.contains("no configuration file"))
|
||||
}
|
||||
|
||||
@Test("loading a missing file names the expanded path")
|
||||
func missingFileIsReported() {
|
||||
let detail = configInvalidDetail {
|
||||
_ = try RunnerConfig.load(from: "/nonexistent/gmr/config.json")
|
||||
}
|
||||
#expect(detail.contains("/nonexistent/gmr/config.json"))
|
||||
}
|
||||
|
||||
// MARK: - load / save round trip
|
||||
|
||||
@Test("load applies validation, so the loaded value is already normalized")
|
||||
func loadNormalizes() throws {
|
||||
let json = """
|
||||
{"gitea": {"instanceURL": "https://g.example.com", "adminToken": "a", "registrationToken": "r"},
|
||||
"scheduler": {"maxConcurrentVMs": 16},
|
||||
"storage": {"storeDir": "~/gmr-store"}}
|
||||
"""
|
||||
let path = try temporaryFile(named: "config.json", contents: json)
|
||||
let c = try RunnerConfig.load(from: path)
|
||||
|
||||
#expect(c.scheduler.maxConcurrentVMs == 2)
|
||||
#expect(!c.storage.storeDir.hasPrefix("~"))
|
||||
}
|
||||
|
||||
@Test("a saved config reloads equal to what was saved")
|
||||
func saveRoundTrips() throws {
|
||||
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("gmr-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
// Nested and not yet created: `save` is expected to make the parents.
|
||||
let path = dir.appendingPathComponent("nested/config.json").path
|
||||
|
||||
let original = try validConfig().validated()
|
||||
try original.save(to: path)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: path))
|
||||
#expect(try RunnerConfig.load(from: path) == original)
|
||||
}
|
||||
|
||||
@Test("writeExample does not clobber an existing file unless told to")
|
||||
func writeExampleRespectsExistingFile() throws {
|
||||
let path = try temporaryFile(named: "config.json", contents: "ORIGINAL")
|
||||
let c = try validConfig().validated()
|
||||
|
||||
#expect(try c.writeExample(to: path, exampleContents: "EXAMPLE") == false)
|
||||
#expect(try String(contentsOfFile: path, encoding: .utf8) == "ORIGINAL")
|
||||
|
||||
#expect(try c.writeExample(to: path, exampleContents: "EXAMPLE", overwrite: true) == true)
|
||||
#expect(try String(contentsOfFile: path, encoding: .utf8) == "EXAMPLE")
|
||||
}
|
||||
|
||||
// MARK: - Tilde expansion
|
||||
|
||||
@Test("expandTilde resolves a leading tilde and leaves other paths alone")
|
||||
func expandTildeBehaviour() {
|
||||
let home = NSHomeDirectory()
|
||||
#expect(RunnerConfig.expandTilde("~/x") == home + "/x")
|
||||
#expect(RunnerConfig.expandTilde("/absolute/x") == "/absolute/x")
|
||||
#expect(RunnerConfig.expandTilde("relative/x") == "relative/x")
|
||||
// A tilde anywhere but the front is an ordinary character.
|
||||
#expect(RunnerConfig.expandTilde("/a/~/b") == "/a/~/b")
|
||||
}
|
||||
|
||||
@Test("validation expands tildes in every path-bearing field")
|
||||
func validationExpandsPaths() throws {
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
c.gitea.adminTokenFile = "~/admin.token"
|
||||
c.gitea.registrationToken = nil
|
||||
c.gitea.registrationTokenFile = "~/reg.token"
|
||||
c.storage.storeDir = "~/gmr"
|
||||
|
||||
let v = try c.validated()
|
||||
let home = NSHomeDirectory()
|
||||
#expect(v.gitea.adminTokenFile == home + "/admin.token")
|
||||
#expect(v.gitea.registrationTokenFile == home + "/reg.token")
|
||||
#expect(v.storage.storeDir == home + "/gmr")
|
||||
#expect(v.storeDirectoryURL.path == home + "/gmr")
|
||||
}
|
||||
|
||||
// MARK: - Validation: instance URL
|
||||
|
||||
@Test("a valid configuration validates unchanged")
|
||||
func validConfigurationSurvivesValidation() throws {
|
||||
let c = try validConfig().validated()
|
||||
#expect(c.gitea.instanceURL.absoluteString == "https://gitea.example.com")
|
||||
#expect(c.gitea.adminToken == "abc123")
|
||||
}
|
||||
|
||||
@Test("a plain http instance URL is allowed")
|
||||
func httpInstanceURLIsAllowed() throws {
|
||||
var c = validConfig()
|
||||
c.gitea.instanceURL = URL(string: "http://gitea.lan:3000")!
|
||||
#expect(throws: Never.self) { try c.validated() }
|
||||
}
|
||||
|
||||
@Test("a non-http scheme is rejected")
|
||||
func nonHTTPSchemeIsRejected() {
|
||||
var c = validConfig()
|
||||
c.gitea.instanceURL = URL(string: "ssh://gitea.example.com")!
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("instanceURL"))
|
||||
}
|
||||
|
||||
@Test("an instance URL with no host is rejected")
|
||||
func hostlessInstanceURLIsRejected() throws {
|
||||
// `#require` rather than a force-unwrap: whether an empty authority
|
||||
// parses at all is a Foundation detail, and a nil here should fail this
|
||||
// one test rather than trap the whole suite.
|
||||
var c = validConfig()
|
||||
c.gitea.instanceURL = try #require(URL(string: "https:///path"))
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("instanceURL"))
|
||||
}
|
||||
|
||||
// MARK: - Validation: admin token
|
||||
|
||||
@Test("no admin token at all names the keys to set")
|
||||
func missingAdminTokenIsRejected() {
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
let detail = configInvalidDetail { _ = try c.validated() }
|
||||
#expect(detail.contains("gitea.adminToken"))
|
||||
#expect(detail.contains("gitea.adminTokenFile"))
|
||||
}
|
||||
|
||||
@Test("both admin token sources set is rejected as ambiguous")
|
||||
func bothAdminTokenSourcesRejected() {
|
||||
// A stale inline token beside a live token file is the shape of a
|
||||
// baffling 401; better to fail at load with the reason spelled out.
|
||||
var c = validConfig()
|
||||
c.gitea.adminTokenFile = "/tmp/admin.token"
|
||||
let detail = configInvalidDetail { _ = try c.validated() }
|
||||
#expect(detail.contains("both"))
|
||||
}
|
||||
|
||||
@Test("a whitespace-only admin token counts as absent")
|
||||
func whitespaceAdminTokenIsAbsent() {
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = " \n "
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("adminToken"))
|
||||
}
|
||||
|
||||
@Test("a surrounding-whitespace admin token is trimmed rather than rejected")
|
||||
func adminTokenIsTrimmed() throws {
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = " abc123\n"
|
||||
#expect(try c.validated().gitea.adminToken == "abc123")
|
||||
}
|
||||
|
||||
// MARK: - Validation: registration token
|
||||
|
||||
@Test("no registration source at all is rejected")
|
||||
func missingRegistrationTokenIsRejected() {
|
||||
var c = validConfig()
|
||||
c.gitea.registrationToken = nil
|
||||
let detail = configInvalidDetail { _ = try c.validated() }
|
||||
#expect(detail.contains("registration"))
|
||||
}
|
||||
|
||||
@Test("the API fallback alone satisfies the registration requirement")
|
||||
func apiFallbackSatisfiesRegistration() throws {
|
||||
var c = validConfig()
|
||||
c.gitea.registrationToken = nil
|
||||
c.gitea.fetchRegistrationTokenViaAPI = true
|
||||
#expect(throws: Never.self) { try c.validated() }
|
||||
}
|
||||
|
||||
@Test("a file and an inline registration token together are allowed")
|
||||
func bothRegistrationSourcesAllowed() throws {
|
||||
// Unlike the admin token: the file simply wins, and the redundancy is
|
||||
// how operators migrate from inline to file without downtime.
|
||||
var c = validConfig()
|
||||
c.gitea.registrationTokenFile = "/tmp/reg.token"
|
||||
#expect(throws: Never.self) { try c.validated() }
|
||||
}
|
||||
|
||||
// MARK: - Validation: labels
|
||||
|
||||
@Test("an empty label list is rejected")
|
||||
func emptyLabelsRejected() {
|
||||
var c = validConfig()
|
||||
c.runner.labels = []
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("runner.labels"))
|
||||
}
|
||||
|
||||
@Test("an empty label name is rejected")
|
||||
func emptyLabelNameRejected() {
|
||||
var c = validConfig()
|
||||
c.runner.labels = ["macos-arm64", " "]
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("empty label"))
|
||||
}
|
||||
|
||||
@Test("a ':schema' suffix in configured labels is rejected")
|
||||
func schemedLabelRejected() {
|
||||
// Gitea reports bare names on jobs, so "macos-arm64:host" in config
|
||||
// would silently match nothing at all — a config error, not a runtime
|
||||
// mystery.
|
||||
var c = validConfig()
|
||||
c.runner.labels = ["macos-arm64:host"]
|
||||
let detail = configInvalidDetail { _ = try c.validated() }
|
||||
#expect(detail.contains("bare names"))
|
||||
}
|
||||
|
||||
@Test("labels are trimmed by validation")
|
||||
func labelsAreTrimmed() throws {
|
||||
var c = validConfig()
|
||||
c.runner.labels = [" macos-arm64 ", "macos"]
|
||||
#expect(try c.validated().runner.labels == ["macos-arm64", "macos"])
|
||||
}
|
||||
|
||||
@Test("the label set derived from config drives job matching")
|
||||
func labelSetMatchesJobs() throws {
|
||||
var c = validConfig()
|
||||
c.runner.labels = ["macos-arm64", "macos"]
|
||||
let set = try c.validated().labelSet
|
||||
#expect(set.matches(jobLabels: ["macos-arm64"]))
|
||||
#expect(!set.matches(jobLabels: ["ubuntu-latest"]))
|
||||
}
|
||||
|
||||
@Test("an empty name prefix is rejected")
|
||||
func emptyNamePrefixRejected() {
|
||||
// An empty prefix would make the reconcile loop treat every runner on
|
||||
// the instance as ours.
|
||||
var c = validConfig()
|
||||
c.runner.namePrefix = " "
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("namePrefix"))
|
||||
}
|
||||
|
||||
@Test("an empty runner version is rejected")
|
||||
func emptyVersionRejected() {
|
||||
var c = validConfig()
|
||||
c.runner.version = ""
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("version"))
|
||||
}
|
||||
|
||||
// MARK: - Validation: scheduler
|
||||
|
||||
@Test("maxConcurrentVMs is clamped to the kernel's limit of 2")
|
||||
func maxConcurrentVMsClampedHigh() throws {
|
||||
// Apple's kernel fails the third `start()` with
|
||||
// VZError.virtualMachineLimitExceeded; that is not negotiable in JSON.
|
||||
for requested in [3, 8, 64, Int.max] {
|
||||
var c = validConfig()
|
||||
c.scheduler.maxConcurrentVMs = requested
|
||||
#expect(try c.validated().scheduler.maxConcurrentVMs == 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("maxConcurrentVMs is clamped up to 1")
|
||||
func maxConcurrentVMsClampedLow() throws {
|
||||
for requested in [0, -1, Int.min] {
|
||||
var c = validConfig()
|
||||
c.scheduler.maxConcurrentVMs = requested
|
||||
#expect(try c.validated().scheduler.maxConcurrentVMs == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an in-range maxConcurrentVMs is left alone")
|
||||
func maxConcurrentVMsInRange() throws {
|
||||
var c = validConfig()
|
||||
c.scheduler.maxConcurrentVMs = 1
|
||||
#expect(try c.validated().scheduler.maxConcurrentVMs == 1)
|
||||
}
|
||||
|
||||
@Test("the hard cap is 2")
|
||||
func hardCapIsTwo() {
|
||||
#expect(RunnerConfig.SchedulerSection.hardMaxConcurrentVMs == 2)
|
||||
}
|
||||
|
||||
@Test("non-positive intervals and timeouts are rejected")
|
||||
func nonPositiveIntervalsRejected() {
|
||||
var poll = validConfig()
|
||||
poll.scheduler.pollIntervalSeconds = 0
|
||||
#expect(configInvalidDetail { _ = try poll.validated() }.contains("pollIntervalSeconds"))
|
||||
|
||||
var reconcile = validConfig()
|
||||
reconcile.scheduler.reconcileIntervalSeconds = -5
|
||||
#expect(
|
||||
configInvalidDetail { _ = try reconcile.validated() }.contains("reconcileIntervalSeconds"))
|
||||
|
||||
var job = validConfig()
|
||||
job.scheduler.jobTimeoutMinutes = 0
|
||||
#expect(configInvalidDetail { _ = try job.validated() }.contains("jobTimeoutMinutes"))
|
||||
|
||||
var boot = validConfig()
|
||||
boot.scheduler.bootTimeoutSeconds = 0
|
||||
#expect(configInvalidDetail { _ = try boot.validated() }.contains("bootTimeoutSeconds"))
|
||||
}
|
||||
|
||||
// MARK: - Validation: guest
|
||||
|
||||
@Test("an empty guest username is rejected")
|
||||
func emptyGuestUsernameRejected() {
|
||||
var c = validConfig()
|
||||
c.guest.username = " "
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("guest.username"))
|
||||
}
|
||||
|
||||
@Test("an empty guest password is rejected")
|
||||
func emptyGuestPasswordRejected() {
|
||||
// SSH password auth is the only channel into the guest; an empty
|
||||
// password turns every boot into an unexplained authentication hang.
|
||||
var c = validConfig()
|
||||
c.guest.password = ""
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("guest.password"))
|
||||
}
|
||||
|
||||
@Test("a guest password of only whitespace is accepted verbatim")
|
||||
func whitespaceGuestPasswordIsKept() throws {
|
||||
// Unlike tokens, the password is not trimmed: whitespace is a legal
|
||||
// part of a password, and silently trimming it would break SSH.
|
||||
var c = validConfig()
|
||||
c.guest.password = " "
|
||||
#expect(try c.validated().guest.password == " ")
|
||||
}
|
||||
|
||||
@Test("under-sized guests are rejected")
|
||||
func undersizedGuestRejected() {
|
||||
var cpu = validConfig()
|
||||
cpu.guest.cpuCount = 0
|
||||
#expect(configInvalidDetail { _ = try cpu.validated() }.contains("cpuCount"))
|
||||
|
||||
var mem = validConfig()
|
||||
mem.guest.memoryGB = 0
|
||||
#expect(configInvalidDetail { _ = try mem.validated() }.contains("memoryGB"))
|
||||
|
||||
var disk = validConfig()
|
||||
disk.guest.diskGB = 0
|
||||
#expect(configInvalidDetail { _ = try disk.validated() }.contains("diskGB"))
|
||||
}
|
||||
|
||||
// MARK: - Validation: storage
|
||||
|
||||
@Test("an empty store directory is rejected")
|
||||
func emptyStoreDirRejected() {
|
||||
var c = validConfig()
|
||||
c.storage.storeDir = " "
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("storeDir"))
|
||||
}
|
||||
|
||||
@Test("a negative free-space floor is rejected")
|
||||
func negativeMinFreeDiskRejected() {
|
||||
var c = validConfig()
|
||||
c.storage.minFreeDiskGB = -1
|
||||
#expect(configInvalidDetail { _ = try c.validated() }.contains("minFreeDiskGB"))
|
||||
}
|
||||
|
||||
@Test("a zero free-space floor is allowed")
|
||||
func zeroMinFreeDiskAllowed() throws {
|
||||
var c = validConfig()
|
||||
c.storage.minFreeDiskGB = 0
|
||||
#expect(throws: Never.self) { try c.validated() }
|
||||
}
|
||||
|
||||
// MARK: - Token resolution
|
||||
|
||||
@Test("an inline admin token resolves to itself")
|
||||
func resolveInlineAdminToken() throws {
|
||||
#expect(try validConfig().resolveAdminToken() == "abc123")
|
||||
}
|
||||
|
||||
@Test("an admin token file is read and trimmed")
|
||||
func resolveAdminTokenFromFile() throws {
|
||||
// `echo secret > token` always leaves a trailing newline; sending that
|
||||
// in an Authorization header is an instant 401.
|
||||
let path = try temporaryFile(contents: " file-token-value\n")
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
c.gitea.adminTokenFile = path
|
||||
|
||||
#expect(try c.resolveAdminToken() == "file-token-value")
|
||||
}
|
||||
|
||||
@Test("the token file wins over an inline value when both are somehow present")
|
||||
func tokenFileTakesPrecedence() throws {
|
||||
// `validated()` rejects this combination, but resolution is also called
|
||||
// on configs assembled in code, so the precedence must be defined.
|
||||
let path = try temporaryFile(contents: "from-file")
|
||||
var c = validConfig()
|
||||
c.gitea.adminTokenFile = path
|
||||
#expect(try c.resolveAdminToken() == "from-file")
|
||||
}
|
||||
|
||||
@Test("resolving from a missing token file names the key and the path")
|
||||
func missingTokenFileIsReported() {
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
c.gitea.adminTokenFile = "/nonexistent/admin.token"
|
||||
|
||||
let detail = configInvalidDetail { _ = try c.resolveAdminToken() }
|
||||
#expect(detail.contains("gitea.adminTokenFile"))
|
||||
#expect(detail.contains("/nonexistent/admin.token"))
|
||||
}
|
||||
|
||||
@Test("an empty token file is rejected rather than yielding an empty token")
|
||||
func emptyTokenFileIsRejected() throws {
|
||||
let path = try temporaryFile(contents: "\n\n \n")
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
c.gitea.adminTokenFile = path
|
||||
|
||||
#expect(configInvalidDetail { _ = try c.resolveAdminToken() }.contains("empty"))
|
||||
}
|
||||
|
||||
@Test("resolving with no admin source configured yields nil, not an error")
|
||||
func resolveAdminTokenWithNoSource() throws {
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
#expect(try c.resolveAdminToken() == nil)
|
||||
}
|
||||
|
||||
@Test("a static registration token resolves from file, then inline")
|
||||
func resolveRegistrationToken() throws {
|
||||
var inline = validConfig()
|
||||
#expect(try inline.resolveStaticRegistrationToken() == "REG123")
|
||||
|
||||
let path = try temporaryFile(named: "reg", contents: "REG-FROM-FILE\n")
|
||||
inline.gitea.registrationTokenFile = path
|
||||
#expect(try inline.resolveStaticRegistrationToken() == "REG-FROM-FILE")
|
||||
}
|
||||
|
||||
@Test("no static registration token yields nil so the caller can use the API")
|
||||
func resolveRegistrationTokenWithNoSource() throws {
|
||||
var c = validConfig()
|
||||
c.gitea.registrationToken = nil
|
||||
c.gitea.fetchRegistrationTokenViaAPI = true
|
||||
#expect(try c.resolveStaticRegistrationToken() == nil)
|
||||
}
|
||||
|
||||
// MARK: - Token file permissions
|
||||
|
||||
@Test("a 0600 token file is not reported as insecure")
|
||||
func ownerOnlyTokenFileIsSecure() throws {
|
||||
let path = try temporaryFile(contents: "s")
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path)
|
||||
|
||||
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable(path) == false)
|
||||
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
c.gitea.adminTokenFile = path
|
||||
#expect(c.insecureTokenFilePaths.isEmpty)
|
||||
}
|
||||
|
||||
@Test("a group- or world-readable token file is flagged but not fatal")
|
||||
func looseTokenFileIsFlagged() throws {
|
||||
// Deliberately a warning: refusing to start over a 0644 file on a
|
||||
// single-user CI Mac would be a poor trade.
|
||||
let path = try temporaryFile(contents: "s")
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: path)
|
||||
|
||||
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable(path) == true)
|
||||
|
||||
var c = validConfig()
|
||||
c.gitea.adminToken = nil
|
||||
c.gitea.adminTokenFile = path
|
||||
#expect(c.insecureTokenFilePaths == [path])
|
||||
// Still valid: the permission check never blocks startup.
|
||||
#expect(throws: Never.self) { try c.validated() }
|
||||
}
|
||||
|
||||
@Test("an unreadable path reports an unknown mode rather than a verdict")
|
||||
func unknownPermissionsAreNil() {
|
||||
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable("/nonexistent/token") == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RunnerCore
|
||||
|
||||
/// Tests for parsing `/var/db/dhcpd_leases`, including the `1,` hardware-type
|
||||
/// prefix, non-zero-padded octets, and newest-lease-wins on duplicate MACs.
|
||||
@Suite("DHCP leases")
|
||||
struct DHCPLeasesTests {
|
||||
/// A file shaped like the real thing: a `1,` prefix on every `hw_address`,
|
||||
/// octets that lack zero padding, one block that is missing its MAC, a
|
||||
/// decimal `lease=` alongside the usual hex ones, a MAC that appears three
|
||||
/// times with different expiries, and a truncated trailing block of the kind
|
||||
/// a mid-write read produces.
|
||||
static let fixture = """
|
||||
{
|
||||
\tname=macos-vm-a
|
||||
\tip_address=192.168.64.2
|
||||
\thw_address=1,aa:bb:c:d:ee:ff
|
||||
\tidentifier=1,aa:bb:c:d:ee:ff
|
||||
\tlease=0x66b2c0de
|
||||
}
|
||||
{
|
||||
\tname=macos-vm-b
|
||||
\tip_address=192.168.64.3
|
||||
\thw_address=1,DE:AD:BE:EF:00:01
|
||||
\tidentifier=1,de:ad:be:ef:00:01
|
||||
\tlease=1723000000
|
||||
}
|
||||
{
|
||||
\tname=no-hardware-address
|
||||
\tip_address=192.168.64.4
|
||||
\tlease=0x66b2c0de
|
||||
}
|
||||
{
|
||||
\tname=macos-vm-a
|
||||
\tip_address=192.168.64.9
|
||||
\thw_address=1,aa:bb:0c:0d:ee:ff
|
||||
\tlease=0x66b2ffff
|
||||
}
|
||||
{
|
||||
\tname=macos-vm-a
|
||||
\tip_address=192.168.64.5
|
||||
\thw_address=1,aa:bb:0c:0d:ee:ff
|
||||
\tlease=0x66b20000
|
||||
}
|
||||
{
|
||||
\tname=truncated-mid-write
|
||||
\tip_address=192.168.64.6
|
||||
"""
|
||||
|
||||
@Test("a lease carries the fields it was constructed with")
|
||||
func leaseIsConstructible() {
|
||||
let lease = DHCPLease(
|
||||
name: "guest",
|
||||
ipAddress: "192.168.64.7",
|
||||
hwAddress: "aa:bb:0c:dd:ee:ff",
|
||||
leaseExpiry: nil
|
||||
)
|
||||
#expect(lease.ipAddress == "192.168.64.7")
|
||||
}
|
||||
|
||||
@Test("well-formed blocks parse and malformed ones are skipped")
|
||||
func parsesWellFormedBlocksOnly() {
|
||||
let leases = DHCPLeaseParser.parse(Self.fixture)
|
||||
|
||||
// Six blocks in the fixture: one has no hw_address and one is truncated.
|
||||
#expect(leases.count == 4)
|
||||
#expect(leases.map(\.ipAddress) == ["192.168.64.2", "192.168.64.3", "192.168.64.9", "192.168.64.5"])
|
||||
#expect(!leases.contains { $0.ipAddress == "192.168.64.4" })
|
||||
#expect(!leases.contains { $0.ipAddress == "192.168.64.6" })
|
||||
#expect(leases[0].name == "macos-vm-a")
|
||||
}
|
||||
|
||||
@Test("the 1, prefix is stripped and octets are zero-padded")
|
||||
func normalizesHardwareAddresses() {
|
||||
let leases = DHCPLeaseParser.parse(Self.fixture)
|
||||
#expect(leases[0].hwAddress == "aa:bb:0c:0d:ee:ff")
|
||||
#expect(leases[1].hwAddress == "de:ad:be:ef:00:01")
|
||||
}
|
||||
|
||||
@Test("lease expiry parses from hex and from decimal")
|
||||
func parsesHexAndDecimalLeaseTimes() {
|
||||
let leases = DHCPLeaseParser.parse(Self.fixture)
|
||||
#expect(leases[0].leaseExpiry == Date(timeIntervalSince1970: TimeInterval(0x66b2_c0de)))
|
||||
#expect(leases[1].leaseExpiry == Date(timeIntervalSince1970: 1_723_000_000))
|
||||
}
|
||||
|
||||
@Test("a duplicate MAC resolves to the newest lease, whatever the file order")
|
||||
func newestLeaseWinsForDuplicateMACs() {
|
||||
let leases = DHCPLeaseParser.parse(Self.fixture)
|
||||
// Three blocks share this MAC: 0x66b2c0de, 0x66b2ffff, then 0x66b20000.
|
||||
// The newest expiry wins even though it is not the last block.
|
||||
#expect(DHCPLeaseParser.ipAddress(forMAC: "aa:bb:0c:0d:ee:ff", in: leases) == "192.168.64.9")
|
||||
}
|
||||
|
||||
@Test("the query MAC is normalized the same way as the file's")
|
||||
func lookupNormalizesTheQuery() {
|
||||
let leases = DHCPLeaseParser.parse(Self.fixture)
|
||||
// Every rendering of the same address must find the same lease.
|
||||
for query in ["aa:bb:c:d:ee:ff", "AA:BB:0C:0D:EE:FF", "aa-bb-0c-0d-ee-ff", "1,aa:bb:c:d:ee:ff"] {
|
||||
#expect(DHCPLeaseParser.ipAddress(forMAC: query, in: leases) == "192.168.64.9")
|
||||
}
|
||||
#expect(DHCPLeaseParser.ipAddress(forMAC: "de:ad:be:ef:00:01", in: leases) == "192.168.64.3")
|
||||
}
|
||||
|
||||
@Test("an unknown or unparseable MAC has no address")
|
||||
func unknownMACHasNoAddress() {
|
||||
let leases = DHCPLeaseParser.parse(Self.fixture)
|
||||
#expect(DHCPLeaseParser.ipAddress(forMAC: "00:11:22:33:44:55", in: leases) == nil)
|
||||
#expect(DHCPLeaseParser.ipAddress(forMAC: "not-a-mac", in: leases) == nil)
|
||||
}
|
||||
|
||||
@Test("a lease is only newer when bootpd actually rewrote it")
|
||||
func leaseFreshnessGate() {
|
||||
// Slot MACs are persistent and leases live 24 h, so the previous guest's
|
||||
// entry is normally still there when the next clone boots. Only a later
|
||||
// expiry (or a different address) proves the new guest has leased.
|
||||
let previous = DHCPLease(
|
||||
name: nil,
|
||||
ipAddress: "192.168.64.7",
|
||||
hwAddress: "aa:bb:0c:dd:ee:ff",
|
||||
leaseExpiry: Date(timeIntervalSince1970: 1_000)
|
||||
)
|
||||
let same = previous
|
||||
let renewed = DHCPLease(
|
||||
name: nil,
|
||||
ipAddress: "192.168.64.7",
|
||||
hwAddress: "aa:bb:0c:dd:ee:ff",
|
||||
leaseExpiry: Date(timeIntervalSince1970: 2_000)
|
||||
)
|
||||
let reassigned = DHCPLease(
|
||||
name: nil,
|
||||
ipAddress: "192.168.64.9",
|
||||
hwAddress: "aa:bb:0c:dd:ee:ff",
|
||||
leaseExpiry: Date(timeIntervalSince1970: 1_000)
|
||||
)
|
||||
|
||||
#expect(!DHCPLeaseParser.isNewer(same, than: previous))
|
||||
#expect(DHCPLeaseParser.isNewer(renewed, than: previous))
|
||||
#expect(DHCPLeaseParser.isNewer(reassigned, than: previous))
|
||||
// First boot on this MAC: there is nothing to be stale against.
|
||||
#expect(DHCPLeaseParser.isNewer(same, than: nil))
|
||||
}
|
||||
|
||||
@Test("lease(forMAC:) returns the same record the address lookup uses")
|
||||
func leaseLookupAgreesWithAddressLookup() {
|
||||
let leases = DHCPLeaseParser.parse(Self.fixture)
|
||||
let lease = DHCPLeaseParser.lease(forMAC: "aa:bb:0c:0d:ee:ff", in: leases)
|
||||
#expect(lease?.ipAddress == "192.168.64.9")
|
||||
#expect(lease?.ipAddress == DHCPLeaseParser.ipAddress(forMAC: "aa:bb:0c:0d:ee:ff", in: leases))
|
||||
#expect(DHCPLeaseParser.lease(forMAC: "00:11:22:33:44:55", in: leases) == nil)
|
||||
}
|
||||
|
||||
@Test("normalizeMAC accepts the renderings bootpd and VZMACAddress produce")
|
||||
func normalizeMACAcceptsCommonForms() {
|
||||
#expect(DHCPLeaseParser.normalizeMAC("1,aa:bb:c:dd:ee:ff") == "aa:bb:0c:dd:ee:ff")
|
||||
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:0c:dd:ee:ff") == "aa:bb:0c:dd:ee:ff")
|
||||
#expect(DHCPLeaseParser.normalizeMAC("AA-BB-0C-DD-EE-FF") == "aa:bb:0c:dd:ee:ff")
|
||||
#expect(DHCPLeaseParser.normalizeMAC(" 1,0:0:0:0:0:1 ") == "00:00:00:00:00:01")
|
||||
}
|
||||
|
||||
@Test("normalizeMAC rejects anything that is not six hex octets")
|
||||
func normalizeMACRejectsGarbage() {
|
||||
#expect(DHCPLeaseParser.normalizeMAC("") == nil)
|
||||
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee") == nil)
|
||||
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee:ff:00") == nil)
|
||||
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee:gg") == nil)
|
||||
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee:fff") == nil)
|
||||
#expect(DHCPLeaseParser.normalizeMAC("192.168.64.2") == nil)
|
||||
}
|
||||
|
||||
@Test("parsing never throws on hostile input")
|
||||
func parsingIsTotal() {
|
||||
#expect(DHCPLeaseParser.parse("").isEmpty)
|
||||
#expect(DHCPLeaseParser.parse("}}}{{{\n=\nname=\n").isEmpty)
|
||||
#expect(DHCPLeaseParser.parse("{\nip_address=1.2.3.4\n").isEmpty)
|
||||
// A block interrupted by the start of the next one is dropped, not merged.
|
||||
let interrupted = """
|
||||
{
|
||||
ip_address=192.168.64.20
|
||||
{
|
||||
ip_address=192.168.64.21
|
||||
hw_address=1,2:2:2:2:2:2
|
||||
}
|
||||
"""
|
||||
let leases = DHCPLeaseParser.parse(interrupted)
|
||||
#expect(leases.count == 1)
|
||||
#expect(leases[0].ipAddress == "192.168.64.21")
|
||||
#expect(leases[0].leaseExpiry == nil)
|
||||
}
|
||||
|
||||
@Test("a missing lease database reads as no leases")
|
||||
func missingFileIsEmpty() {
|
||||
#expect(DHCPLeaseParser.parseFile(at: "/var/db/definitely-not-a-lease-file").isEmpty)
|
||||
}
|
||||
|
||||
@Test("a lease database on disk round-trips through parseFile")
|
||||
func parsesFromDisk() throws {
|
||||
let path = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("dhcpd_leases_test_\(UUID().uuidString)")
|
||||
try Self.fixture.write(to: path, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(at: path) }
|
||||
|
||||
let leases = DHCPLeaseParser.parseFile(at: path.path)
|
||||
#expect(DHCPLeaseParser.ipAddress(forMAC: "aa:bb:c:d:ee:ff", in: leases) == "192.168.64.9")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RunnerCore
|
||||
|
||||
/// Tests for the Gitea API models' JSON mapping.
|
||||
///
|
||||
/// The fixtures below are written from the shapes Gitea actually serializes,
|
||||
/// verified against `modules/structs/repo_actions.go` and
|
||||
/// `routers/api/v1/shared/{action,runners}.go` at tag `v1.25.0`, cross-checked
|
||||
/// against the published swagger (`ActionWorkflowJobsResponse`,
|
||||
/// `ActionRunnersResponse`, `ActionRunner`, `ActionRunnerLabel`). Two details
|
||||
/// are easy to get wrong and are pinned here deliberately:
|
||||
///
|
||||
/// * a job's `labels` is an array of **strings**, but a runner's `labels` is an
|
||||
/// array of **objects** (`{id, name, type}`);
|
||||
/// * timestamps are Go `time.Time` values, so fractional seconds appear only
|
||||
/// when non-zero and an unset time serializes as `0001-01-01T00:00:00Z`.
|
||||
@Suite("Gitea models")
|
||||
struct GiteaModelsTests {
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
|
||||
try GiteaClient.makeDecoder().decode(T.self, from: Data(json.utf8))
|
||||
}
|
||||
|
||||
// MARK: - Job status
|
||||
|
||||
@Test("a job with status 'queued' is schedulable")
|
||||
func queuedStatusIsSchedulable() {
|
||||
let job = WorkflowJob(id: 1, runID: 2, name: "build", status: "queued", labels: ["macos-arm64"])
|
||||
#expect(job.isQueued)
|
||||
#expect(job.jobStatus == .queued)
|
||||
}
|
||||
|
||||
@Test("'waiting' means blocked and is never schedulable")
|
||||
func waitingIsNotSchedulable() {
|
||||
// Gitea maps the external "queued" onto its internal StatusWaiting, and
|
||||
// the external "waiting" onto StatusBlocked — a job waiting on a
|
||||
// dependency, not on a runner. Booting a VM for one would be pure waste.
|
||||
let job = WorkflowJob(id: 1, runID: 2, name: "build", status: "waiting", labels: ["macos-arm64"])
|
||||
#expect(!job.isQueued)
|
||||
#expect(job.jobStatus == .waiting)
|
||||
}
|
||||
|
||||
@Test("every reported status maps to a case, unknown strings included")
|
||||
func statusMapping() {
|
||||
#expect(JobStatus(rawValue: "queued") == .queued)
|
||||
#expect(JobStatus(rawValue: "waiting") == .waiting)
|
||||
#expect(JobStatus(rawValue: "in_progress") == .inProgress)
|
||||
#expect(JobStatus(rawValue: "completed") == .completed)
|
||||
// A status this build has never heard of must round-trip, not throw:
|
||||
// the mapping is Gitea's to change, and a decode failure would stop the
|
||||
// poll loop entirely.
|
||||
#expect(JobStatus(rawValue: "some_new_status") == .unknown("some_new_status"))
|
||||
#expect(JobStatus(rawValue: "some_new_status").rawValue == "some_new_status")
|
||||
}
|
||||
|
||||
// MARK: - Job decoding
|
||||
|
||||
@Test("a realistic queued-job response decodes")
|
||||
func decodeQueuedJobsResponse() throws {
|
||||
let json = """
|
||||
{
|
||||
"total_count": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": 4711,
|
||||
"url": "https://gitea.example.com/api/v1/repos/acme/widget/actions/jobs/4711",
|
||||
"html_url": "https://gitea.example.com/acme/widget/actions/runs/12/jobs/0",
|
||||
"run_id": 12,
|
||||
"run_url": "https://gitea.example.com/api/v1/repos/acme/widget/actions/runs/12",
|
||||
"name": "build (macos)",
|
||||
"labels": ["macos-arm64"],
|
||||
"run_attempt": 1,
|
||||
"head_sha": "0f1e2d3c4b5a69788796a5b4c3d2e1f001234567",
|
||||
"head_branch": "main",
|
||||
"status": "queued",
|
||||
"steps": [],
|
||||
"created_at": "2026-08-07T09:15:04Z",
|
||||
"started_at": "0001-01-01T00:00:00Z",
|
||||
"completed_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let response = try decode(WorkflowJobsResponse.self, json)
|
||||
#expect(response.totalCount == 1)
|
||||
#expect(response.items.count == 1)
|
||||
|
||||
let job = try #require(response.items.first)
|
||||
#expect(job.id == 4711)
|
||||
#expect(job.runID == 12)
|
||||
#expect(job.name == "build (macos)")
|
||||
#expect(job.status == "queued")
|
||||
#expect(job.isQueued)
|
||||
#expect(job.labels == ["macos-arm64"])
|
||||
// `runner_id` / `runner_name` carry omitempty and are absent while queued.
|
||||
#expect(job.runnerID == nil)
|
||||
#expect(job.runnerName == nil)
|
||||
#expect(job.createdAt != nil)
|
||||
// Go's zero time must not surface as a year-1 date.
|
||||
#expect(job.startedAt == nil)
|
||||
#expect(job.completedAt == nil)
|
||||
}
|
||||
|
||||
@Test("a running job reports its runner")
|
||||
func decodeRunningJob() throws {
|
||||
let json = """
|
||||
{
|
||||
"total_count": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": 4712,
|
||||
"run_id": 12,
|
||||
"name": "test",
|
||||
"labels": ["macos-arm64", "self-hosted"],
|
||||
"status": "in_progress",
|
||||
"runner_id": 9,
|
||||
"runner_name": "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f",
|
||||
"created_at": "2026-08-07T09:15:04Z",
|
||||
"started_at": "2026-08-07T09:16:31.482913Z",
|
||||
"completed_at": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let job = try #require(try decode(WorkflowJobsResponse.self, json).items.first)
|
||||
#expect(!job.isQueued)
|
||||
#expect(job.jobStatus == .inProgress)
|
||||
#expect(job.runnerID == 9)
|
||||
#expect(job.runnerName == "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f")
|
||||
// Fractional seconds are present here and absent above — both must parse.
|
||||
#expect(job.startedAt != nil)
|
||||
#expect(job.completedAt == nil)
|
||||
}
|
||||
|
||||
@Test("an unknown status string decodes rather than throwing")
|
||||
func unknownStatusTolerated() throws {
|
||||
let json = """
|
||||
{"total_count": 1, "jobs": [
|
||||
{"id": 1, "run_id": 1, "name": "j", "labels": [], "status": "quantum_superposition"}
|
||||
]}
|
||||
"""
|
||||
let job = try #require(try decode(WorkflowJobsResponse.self, json).items.first)
|
||||
#expect(job.status == "quantum_superposition")
|
||||
#expect(job.jobStatus == .unknown("quantum_superposition"))
|
||||
#expect(!job.isQueued)
|
||||
}
|
||||
|
||||
@Test("an empty jobs response decodes to no jobs")
|
||||
func decodeEmptyJobsResponse() throws {
|
||||
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0, "jobs": []}"#).items.isEmpty)
|
||||
// A server that omits the array entirely, or nulls it, must not throw:
|
||||
// "nothing queued" is the overwhelmingly common case in the poll loop.
|
||||
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0}"#).items.isEmpty)
|
||||
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0, "jobs": null}"#).items.isEmpty)
|
||||
#expect(try decode(WorkflowJobsResponse.self, "{}").items.isEmpty)
|
||||
}
|
||||
|
||||
@Test("the 'workflow_jobs' array key is accepted as a fallback")
|
||||
func decodeAlternateJobsKey() throws {
|
||||
let json = """
|
||||
{"total_count": 1, "workflow_jobs": [
|
||||
{"id": 7, "run_id": 1, "name": "j", "labels": ["macos-arm64"], "status": "queued"}
|
||||
]}
|
||||
"""
|
||||
let response = try decode(WorkflowJobsResponse.self, json)
|
||||
#expect(response.items.map(\.id) == [7])
|
||||
}
|
||||
|
||||
@Test("a job round-trips through encode and decode")
|
||||
func jobRoundTrip() throws {
|
||||
let original = WorkflowJobsResponse(
|
||||
totalCount: 1,
|
||||
jobs: [WorkflowJob(id: 1, runID: 2, name: "n", status: "queued", labels: ["macos-arm64"])])
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(original)
|
||||
let decoded = try GiteaClient.makeDecoder().decode(WorkflowJobsResponse.self, from: data)
|
||||
#expect(decoded == original)
|
||||
}
|
||||
|
||||
// MARK: - Runner decoding
|
||||
|
||||
@Test("a runners response with object-shaped labels decodes")
|
||||
func decodeRunnersResponse() throws {
|
||||
// This is the shape that matters most: `ActionRunner.Labels` is
|
||||
// `[]*ActionRunnerLabel`, NOT `[]string` as on a job.
|
||||
let json = """
|
||||
{
|
||||
"total_count": 2,
|
||||
"runners": [
|
||||
{
|
||||
"id": 9,
|
||||
"name": "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f",
|
||||
"status": "online",
|
||||
"busy": false,
|
||||
"ephemeral": true,
|
||||
"labels": [
|
||||
{"id": 31, "name": "macos-arm64", "type": "custom"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "shared-linux-1",
|
||||
"status": "offline",
|
||||
"busy": true,
|
||||
"ephemeral": false,
|
||||
"labels": [
|
||||
{"id": 32, "name": "ubuntu-latest", "type": "custom"},
|
||||
{"id": 33, "name": "self-hosted", "type": "custom"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let response = try decode(RunnersResponse.self, json)
|
||||
#expect(response.totalCount == 2)
|
||||
#expect(response.items.count == 2)
|
||||
|
||||
let ours = try #require(response.items.first)
|
||||
#expect(ours.id == 9)
|
||||
#expect(ours.labels == ["macos-arm64"])
|
||||
#expect(ours.status == "online")
|
||||
#expect(ours.isEphemeral)
|
||||
#expect(!ours.isBusy)
|
||||
// All four reconcile conditions are readable from this row.
|
||||
#expect(RunnerNaming.hasPrefix(ours.name, prefix: "macos-vm-"))
|
||||
|
||||
let theirs = try #require(response.items.last)
|
||||
#expect(theirs.labels == ["ubuntu-latest", "self-hosted"])
|
||||
#expect(!theirs.isEphemeral)
|
||||
#expect(theirs.isBusy)
|
||||
#expect(!RunnerNaming.hasPrefix(theirs.name, prefix: "macos-vm-"))
|
||||
}
|
||||
|
||||
@Test("string-shaped runner labels are also accepted")
|
||||
func decodeRunnerWithStringLabels() throws {
|
||||
// Defensive: a proxy, an older build, or a hand-written fixture may use
|
||||
// the job-style array of strings.
|
||||
let json = #"{"id": 1, "name": "r", "labels": ["macos-arm64", "macos"]}"#
|
||||
let runner = try decode(ActionRunner.self, json)
|
||||
#expect(runner.labels == ["macos-arm64", "macos"])
|
||||
}
|
||||
|
||||
@Test("absent, null, and empty runner labels all decode to no labels")
|
||||
func decodeRunnerWithoutLabels() throws {
|
||||
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r"}"#).labels.isEmpty)
|
||||
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": null}"#).labels.isEmpty)
|
||||
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": []}"#).labels.isEmpty)
|
||||
}
|
||||
|
||||
@Test("omitted busy and ephemeral default to false")
|
||||
func runnerFlagDefaults() throws {
|
||||
// The reconcile loop only ever deletes a row it is sure is ephemeral and
|
||||
// idle, so absence must read as "not ephemeral", never as "assume yes".
|
||||
let runner = try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": []}"#)
|
||||
#expect(runner.busy == nil)
|
||||
#expect(runner.ephemeral == nil)
|
||||
#expect(!runner.isBusy)
|
||||
#expect(!runner.isEphemeral)
|
||||
}
|
||||
|
||||
@Test("an unknown runner status string is tolerated")
|
||||
func runnerUnknownStatus() throws {
|
||||
let json = #"{"id": 1, "name": "r", "labels": [], "status": "hibernating"}"#
|
||||
#expect(try decode(ActionRunner.self, json).status == "hibernating")
|
||||
}
|
||||
|
||||
@Test("extra server-side fields are ignored")
|
||||
func unknownFieldsIgnored() throws {
|
||||
// Gitea 1.27 adds `disabled` to ActionRunner; newer fields must not
|
||||
// break a 1.25-era client.
|
||||
let json = """
|
||||
{"id": 1, "name": "r", "labels": [], "disabled": false, "some_future_field": {"a": 1}}
|
||||
"""
|
||||
#expect(try decode(ActionRunner.self, json).id == 1)
|
||||
}
|
||||
|
||||
@Test("an empty runners response decodes to no runners")
|
||||
func decodeEmptyRunnersResponse() throws {
|
||||
#expect(try decode(RunnersResponse.self, #"{"total_count": 0, "runners": []}"#).items.isEmpty)
|
||||
#expect(try decode(RunnersResponse.self, "{}").items.isEmpty)
|
||||
}
|
||||
|
||||
@Test("the 'entries' array key is accepted as a fallback")
|
||||
func decodeAlternateRunnersKey() throws {
|
||||
let json = #"{"total_count": 1, "entries": [{"id": 5, "name": "r", "labels": []}]}"#
|
||||
#expect(try decode(RunnersResponse.self, json).items.map(\.id) == [5])
|
||||
}
|
||||
|
||||
// MARK: - Registration token
|
||||
|
||||
@Test("a registration-token response decodes")
|
||||
func decodeRegistrationToken() throws {
|
||||
// Verified: `shared.RegistrationToken` is `{Token string `json:"token"`}`.
|
||||
let response = try decode(RegistrationTokenResponse.self, #"{"token": "AABBCCDDEEFF00112233"}"#)
|
||||
#expect(response.token == "AABBCCDDEEFF00112233")
|
||||
}
|
||||
|
||||
// MARK: - Timestamps
|
||||
|
||||
@Test("timestamps parse with and without fractional seconds")
|
||||
func timestampParsing() throws {
|
||||
// Go marshals time.Time as RFC 3339 Nano: the fractional part appears
|
||||
// only when non-zero, so both spellings occur in one response.
|
||||
let plain = try #require(GiteaClient.parseTimestamp("2026-08-07T09:15:04Z"))
|
||||
let fractional = try #require(GiteaClient.parseTimestamp("2026-08-07T09:15:04.482913Z"))
|
||||
#expect(fractional > plain)
|
||||
#expect(fractional.timeIntervalSince(plain) < 1)
|
||||
|
||||
// An offset rather than Z.
|
||||
let offset = try #require(GiteaClient.parseTimestamp("2026-08-07T11:15:04+02:00"))
|
||||
#expect(offset == plain)
|
||||
|
||||
#expect(GiteaClient.parseTimestamp("not a date") == nil)
|
||||
}
|
||||
|
||||
@Test("a malformed timestamp fails the field, loudly")
|
||||
func malformedTimestampThrows() {
|
||||
let json = """
|
||||
{"total_count": 1, "jobs": [
|
||||
{"id": 1, "run_id": 1, "name": "j", "labels": [], "status": "queued",
|
||||
"created_at": "yesterday"}
|
||||
]}
|
||||
"""
|
||||
#expect(throws: DecodingError.self) {
|
||||
try decode(WorkflowJobsResponse.self, json)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RunnerCore
|
||||
|
||||
/// Tests for ``LabelSet`` matching against a job's bare `runs-on` labels.
|
||||
///
|
||||
/// The semantics under test are exactly: a job matches iff its label array is
|
||||
/// non-empty and every entry is one of ours. That is a subset test with an
|
||||
/// explicit carve-out for the empty set, which subset semantics would otherwise
|
||||
/// accept.
|
||||
@Suite("LabelSet")
|
||||
struct LabelsTests {
|
||||
|
||||
// MARK: - Construction
|
||||
|
||||
@Test("bare names are kept verbatim")
|
||||
func bareNamesAreKept() {
|
||||
let set = LabelSet(["macos-arm64", "macos"])
|
||||
#expect(set.names == ["macos-arm64", "macos"])
|
||||
}
|
||||
|
||||
@Test("a ':schema' suffix is stripped at construction")
|
||||
func schemaSuffixIsStripped() {
|
||||
// Safe to hand this the same array the config file holds, even if an
|
||||
// operator wrote the registration form by mistake.
|
||||
let set = LabelSet(["macos-arm64:host", "macos:docker"])
|
||||
#expect(set.names == ["macos-arm64", "macos"])
|
||||
}
|
||||
|
||||
@Test("empty and whitespace-only names are dropped")
|
||||
func emptyNamesAreDropped() {
|
||||
let set = LabelSet(["macos-arm64", "", " ", ":host"])
|
||||
#expect(set.names == ["macos-arm64"])
|
||||
}
|
||||
|
||||
@Test("bareName strips at the first colon only")
|
||||
func bareNameStripsAtFirstColon() {
|
||||
#expect(LabelSet.bareName("macos-arm64") == "macos-arm64")
|
||||
#expect(LabelSet.bareName("macos-arm64:host") == "macos-arm64")
|
||||
#expect(LabelSet.bareName("a:b:c") == "a")
|
||||
#expect(LabelSet.bareName(" macos:host") == "macos")
|
||||
}
|
||||
|
||||
// MARK: - Matching
|
||||
|
||||
@Test("an exact single-label match succeeds")
|
||||
func exactMatch() {
|
||||
#expect(LabelSet(["macos-arm64"]).matches(jobLabels: ["macos-arm64"]))
|
||||
}
|
||||
|
||||
@Test("a job asking for a subset of our labels matches")
|
||||
func subsetMatches() {
|
||||
// The runner is a superset: it advertises more than the job needs.
|
||||
let set = LabelSet(["macos-arm64", "macos", "self-hosted"])
|
||||
#expect(set.matches(jobLabels: ["macos-arm64"]))
|
||||
#expect(set.matches(jobLabels: ["macos-arm64", "self-hosted"]))
|
||||
#expect(set.matches(jobLabels: ["macos-arm64", "macos", "self-hosted"]))
|
||||
}
|
||||
|
||||
@Test("a job asking for anything we lack does not match")
|
||||
func supersetDoesNotMatch() {
|
||||
let set = LabelSet(["macos-arm64"])
|
||||
#expect(!set.matches(jobLabels: ["ubuntu-latest"]))
|
||||
// One unknown label is enough to disqualify the whole job.
|
||||
#expect(!set.matches(jobLabels: ["macos-arm64", "xcode-16"]))
|
||||
}
|
||||
|
||||
@Test("an empty job label array never matches")
|
||||
func emptyJobLabelsDoNotMatch() {
|
||||
// Subset semantics alone would say yes — the empty set is a subset of
|
||||
// everything. A job that declares no requirement must not consume one of
|
||||
// two scarce macOS VMs.
|
||||
#expect(!LabelSet(["macos-arm64"]).matches(jobLabels: []))
|
||||
#expect(!LabelSet([]).matches(jobLabels: []))
|
||||
}
|
||||
|
||||
@Test("an empty runner label set matches nothing")
|
||||
func emptyRunnerLabelsMatchNothing() {
|
||||
#expect(!LabelSet([]).matches(jobLabels: ["macos-arm64"]))
|
||||
}
|
||||
|
||||
@Test("matching is case-sensitive")
|
||||
func matchingIsCaseSensitive() {
|
||||
// Gitea compares labels case-sensitively, so `macOS-ARM64` in a workflow
|
||||
// is a different label from `macos-arm64` and must not be claimed.
|
||||
let set = LabelSet(["macos-arm64"])
|
||||
#expect(!set.matches(jobLabels: ["macOS-ARM64"]))
|
||||
#expect(!set.matches(jobLabels: ["MACOS-ARM64"]))
|
||||
#expect(set.matches(jobLabels: ["macos-arm64"]))
|
||||
}
|
||||
|
||||
@Test("a schema suffix on the job side is stripped before matching")
|
||||
func jobLabelsAreNormalized() {
|
||||
// The server stores bare names, so this is unusual — but a workflow that
|
||||
// writes `runs-on: [macos-arm64:host]` would otherwise be skipped
|
||||
// silently, with no log line to explain why.
|
||||
let set = LabelSet(["macos-arm64", "macos"])
|
||||
#expect(set.matches(jobLabels: ["macos-arm64:host"]))
|
||||
#expect(set.matches(jobLabels: ["macos-arm64:host", "macos"]))
|
||||
#expect(!set.matches(jobLabels: ["ubuntu-latest:host"]))
|
||||
// A label that is nothing but a schema separator is not a match.
|
||||
#expect(!set.matches(jobLabels: [":host"]))
|
||||
}
|
||||
|
||||
@Test("duplicate job labels are harmless")
|
||||
func duplicateJobLabels() {
|
||||
#expect(LabelSet(["macos-arm64"]).matches(jobLabels: ["macos-arm64", "macos-arm64"]))
|
||||
}
|
||||
|
||||
// MARK: - Registration argument
|
||||
|
||||
@Test("the registration argument appends the schema to every name")
|
||||
func registrationArgumentAppendsSchema() {
|
||||
#expect(LabelSet(["macos-arm64"]).registrationArgument() == "macos-arm64:host")
|
||||
#expect(
|
||||
LabelSet(["macos-arm64", "macos"]).registrationArgument() == "macos:host,macos-arm64:host")
|
||||
}
|
||||
|
||||
@Test("the registration argument is stable across calls")
|
||||
func registrationArgumentIsStable() {
|
||||
// `names` is a Set; sorting is what keeps the guest command line — and
|
||||
// therefore its logs — reproducible.
|
||||
let set = LabelSet(["zulu", "alpha", "mike"])
|
||||
#expect(set.registrationArgument() == set.registrationArgument())
|
||||
#expect(set.registrationArgument() == "alpha:host,mike:host,zulu:host")
|
||||
}
|
||||
|
||||
@Test("a non-default schema is honoured")
|
||||
func registrationArgumentWithCustomSchema() {
|
||||
#expect(LabelSet(["ubuntu"]).registrationArgument(schema: "docker") == "ubuntu:docker")
|
||||
}
|
||||
|
||||
@Test("an empty label set produces an empty registration argument")
|
||||
func registrationArgumentOfEmptySet() {
|
||||
#expect(LabelSet([]).registrationArgument() == "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests for ``RunnerNaming``.
|
||||
@Suite("RunnerNaming")
|
||||
struct RunnerNamingTests {
|
||||
|
||||
@Test("a generated name carries the prefix and a lowercase UUID")
|
||||
func generatedNameShape() throws {
|
||||
let name = RunnerNaming.makeRunnerName(prefix: "macos-vm-")
|
||||
#expect(name.hasPrefix("macos-vm-"))
|
||||
|
||||
let suffix = String(name.dropFirst("macos-vm-".count))
|
||||
#expect(suffix == suffix.lowercased())
|
||||
#expect(UUID(uuidString: suffix) != nil)
|
||||
}
|
||||
|
||||
@Test("generated names are unique")
|
||||
func generatedNamesAreUnique() {
|
||||
// Uniqueness is what lets the reconcile loop decide a row is ours and
|
||||
// unbacked; a collision would make that decision unsound.
|
||||
let names = Set((0..<200).map { _ in RunnerNaming.makeRunnerName(prefix: "macos-vm-") })
|
||||
#expect(names.count == 200)
|
||||
}
|
||||
|
||||
@Test("prefix detection matches only our names")
|
||||
func prefixDetection() {
|
||||
#expect(RunnerNaming.hasPrefix("macos-vm-abc", prefix: "macos-vm-"))
|
||||
#expect(!RunnerNaming.hasPrefix("linux-runner-1", prefix: "macos-vm-"))
|
||||
#expect(!RunnerNaming.hasPrefix("MACOS-VM-abc", prefix: "macos-vm-"))
|
||||
#expect(!RunnerNaming.hasPrefix("x-macos-vm-abc", prefix: "macos-vm-"))
|
||||
}
|
||||
|
||||
@Test("an empty prefix matches nothing")
|
||||
func emptyPrefixMatchesNothing() {
|
||||
// Otherwise the reconcile loop would consider every runner on the
|
||||
// instance — including other hosts' — a deletion candidate.
|
||||
#expect(!RunnerNaming.hasPrefix("anything", prefix: ""))
|
||||
}
|
||||
|
||||
@Test("a generated name is recognized by its own prefix")
|
||||
func roundTrip() {
|
||||
let name = RunnerNaming.makeRunnerName(prefix: "macos-vm-")
|
||||
#expect(RunnerNaming.hasPrefix(name, prefix: "macos-vm-"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RunnerCore
|
||||
|
||||
/// Tests for the pure scheduling state machine.
|
||||
///
|
||||
/// Every case drives ``SchedulerCore/plan(state:queuedJobs:labels:maxVMs:now:jobTimeout:bootTimeout:)``
|
||||
/// with an injected `now`, so nothing here touches a clock, the network, or a VM.
|
||||
@Suite("SchedulerCore")
|
||||
struct SchedulerCoreTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
static let labels = LabelSet(["macos-arm64", "macos"])
|
||||
static let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
static let jobTimeout: TimeInterval = 3600
|
||||
static let bootTimeout: TimeInterval = 300
|
||||
|
||||
static func job(_ id: Int64, labels: [String] = ["macos-arm64"]) -> WorkflowJob {
|
||||
WorkflowJob(id: id, runID: id * 10, name: "job-\(id)", status: "queued", labels: labels)
|
||||
}
|
||||
|
||||
/// Plans one tick with the suite's fixed labels and timeouts.
|
||||
static func tick(
|
||||
_ state: SchedulerState,
|
||||
_ queued: [WorkflowJob],
|
||||
maxVMs: Int = 2,
|
||||
now: Date = SchedulerCoreTests.now
|
||||
) -> (SchedulerState, [SchedulerAction]) {
|
||||
SchedulerCore.plan(
|
||||
state: state,
|
||||
queuedJobs: queued,
|
||||
labels: labels,
|
||||
maxVMs: maxVMs,
|
||||
now: now,
|
||||
jobTimeout: jobTimeout,
|
||||
bootTimeout: bootTimeout
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Baseline
|
||||
|
||||
@Test("a fresh state has no VMs and no ledger")
|
||||
func freshStateIsAllIdle() {
|
||||
let state = SchedulerState(slotCount: 2)
|
||||
#expect(state.slots.count == 2)
|
||||
#expect(state.idleSlots.count == 2)
|
||||
#expect(state.occupiedSlots.isEmpty)
|
||||
#expect(state.dispatchedJobIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("an empty queue is a no-op")
|
||||
func emptyQueueDoesNothing() {
|
||||
let state = SchedulerState(slotCount: 2)
|
||||
let (next, actions) = Self.tick(state, [])
|
||||
#expect(actions.isEmpty)
|
||||
#expect(next == state)
|
||||
}
|
||||
|
||||
// MARK: - Booting
|
||||
|
||||
@Test("one queued job boots one VM")
|
||||
func oneJobBootsOneVM() {
|
||||
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
|
||||
|
||||
#expect(actions == [.bootVM(slot: 0, jobHint: 1)])
|
||||
#expect(next.slots[0].state == .provisioning(since: Self.now))
|
||||
#expect(next.slots[1].state == .idle)
|
||||
#expect(next.dispatchedJobIDs == [1])
|
||||
}
|
||||
|
||||
@Test("the same job across two ticks boots only one VM")
|
||||
func dedupsAcrossTicks() {
|
||||
let queue = [Self.job(1)]
|
||||
let (afterFirst, firstActions) = Self.tick(SchedulerState(slotCount: 2), queue)
|
||||
// The job is still queued a poll later: the VM has not registered yet.
|
||||
let (afterSecond, secondActions) = Self.tick(
|
||||
afterFirst, queue, now: Self.now.addingTimeInterval(10))
|
||||
|
||||
#expect(firstActions == [.bootVM(slot: 0, jobHint: 1)])
|
||||
#expect(secondActions.isEmpty)
|
||||
#expect(afterSecond.occupiedSlots.count == 1)
|
||||
#expect(afterSecond.dispatchedJobIDs == [1])
|
||||
}
|
||||
|
||||
@Test("a job still queued while its VM is running does not boot a second VM")
|
||||
func dedupSurvivesTheRunningTransition() {
|
||||
let queue = [Self.job(1)]
|
||||
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), queue)
|
||||
let running = SchedulerCore.markRunning(state: booted, slot: 0, jobHint: 1, now: Self.now)
|
||||
|
||||
let (next, actions) = Self.tick(running, queue, now: Self.now.addingTimeInterval(30))
|
||||
#expect(actions.isEmpty)
|
||||
#expect(next.occupiedSlots.count == 1)
|
||||
}
|
||||
|
||||
@Test("two jobs boot two VMs but a third waits for capacity")
|
||||
func capacityIsCapped() {
|
||||
let queue = [Self.job(1), Self.job(2), Self.job(3)]
|
||||
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), queue)
|
||||
|
||||
#expect(actions == [.bootVM(slot: 0, jobHint: 1), .bootVM(slot: 1, jobHint: 2)])
|
||||
#expect(next.occupiedSlots.count == 2)
|
||||
// Job 3 never entered the ledger, so it is eligible the moment a slot frees.
|
||||
#expect(next.dispatchedJobIDs == [1, 2])
|
||||
}
|
||||
|
||||
@Test("maxVMs above two is clamped to the kernel's concurrent-guest limit")
|
||||
func maxVMsIsClampedToTwo() {
|
||||
let queue = [Self.job(1), Self.job(2), Self.job(3), Self.job(4)]
|
||||
let (next, actions) = Self.tick(SchedulerState(slotCount: 4), queue, maxVMs: 5)
|
||||
|
||||
#expect(actions.count == 2)
|
||||
#expect(next.occupiedSlots.count == 2)
|
||||
}
|
||||
|
||||
@Test("maxVMs of zero boots nothing")
|
||||
func zeroCapacityBootsNothing() {
|
||||
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)], maxVMs: 0)
|
||||
#expect(actions.isEmpty)
|
||||
#expect(next.occupiedSlots.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Label matching
|
||||
|
||||
@Test("jobs whose labels do not match are ignored")
|
||||
func nonMatchingLabelsAreIgnored() {
|
||||
let queue = [
|
||||
Self.job(1, labels: ["ubuntu-latest"]),
|
||||
Self.job(2, labels: ["windows-2022", "self-hosted"]),
|
||||
]
|
||||
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), queue)
|
||||
|
||||
#expect(actions.isEmpty)
|
||||
#expect(next.dispatchedJobIDs.isEmpty)
|
||||
#expect(next.occupiedSlots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("a matching job among non-matching ones still boots")
|
||||
func matchingJobIsPickedOutOfAMixedQueue() {
|
||||
let queue = [
|
||||
Self.job(1, labels: ["ubuntu-latest"]),
|
||||
Self.job(2, labels: ["macos-arm64"]),
|
||||
Self.job(3, labels: ["ubuntu-latest"]),
|
||||
]
|
||||
let (_, actions) = Self.tick(SchedulerState(slotCount: 2), queue)
|
||||
#expect(actions == [.bootVM(slot: 0, jobHint: 2)])
|
||||
}
|
||||
|
||||
// MARK: - Ledger expiry
|
||||
|
||||
@Test("a job that leaves the queue drops out of the dedup ledger")
|
||||
func dequeuedJobClearsItsLedgerEntry() {
|
||||
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
|
||||
let running = SchedulerCore.markRunning(state: booted, slot: 0, jobHint: 1, now: Self.now)
|
||||
#expect(running.dispatchedJobIDs == [1])
|
||||
|
||||
// The VM registered and claimed job 1, so Gitea no longer reports it queued.
|
||||
let (next, actions) = Self.tick(running, [], now: Self.now.addingTimeInterval(60))
|
||||
#expect(actions.isEmpty)
|
||||
#expect(next.dispatchedJobIDs.isEmpty)
|
||||
#expect(next.occupiedSlots.count == 1)
|
||||
}
|
||||
|
||||
@Test("releasing a job lets a still-queued job boot again after a failure")
|
||||
func releasedJobIsRedispatched() {
|
||||
// A boot that failed (no disk, clone error, dead SSH) leaves its job
|
||||
// queued, so the ledger's "no longer queued" expiry never fires for it.
|
||||
// Without the explicit release the job is stranded for good.
|
||||
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
|
||||
#expect(booted.dispatchedJobIDs == [1])
|
||||
|
||||
let failed = SchedulerCore.releaseJob(
|
||||
state: SchedulerCore.markIdle(state: booted, slot: 0),
|
||||
jobID: 1
|
||||
)
|
||||
#expect(failed.dispatchedJobIDs.isEmpty)
|
||||
|
||||
let (next, actions) = Self.tick(failed, [Self.job(1)], now: Self.now.addingTimeInterval(30))
|
||||
#expect(actions == [.bootVM(slot: 0, jobHint: 1)])
|
||||
#expect(next.dispatchedJobIDs == [1])
|
||||
}
|
||||
|
||||
@Test("releasing an id that was never dispatched is a no-op")
|
||||
func releasingAnUnknownJobIsHarmless() {
|
||||
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
|
||||
let after = SchedulerCore.releaseJob(state: booted, jobID: 99)
|
||||
#expect(after.dispatchedJobIDs == [1])
|
||||
#expect(SchedulerCore.releaseJob(state: after, jobID: 1).dispatchedJobIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("a freed slot is re-earned by a genuinely new job")
|
||||
func freedCapacityServesTheNextJob() {
|
||||
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
|
||||
let done = SchedulerCore.markIdle(state: booted, slot: 0)
|
||||
|
||||
let (next, actions) = Self.tick(done, [Self.job(2)], now: Self.now.addingTimeInterval(120))
|
||||
#expect(actions == [.bootVM(slot: 0, jobHint: 2)])
|
||||
#expect(next.dispatchedJobIDs == [2])
|
||||
}
|
||||
|
||||
// MARK: - Timeouts
|
||||
|
||||
@Test("a stuck boot is torn down and replaced in the same pass")
|
||||
func bootTimeoutTearsDownAndAllowsAReplacement() {
|
||||
let stuckSince = Self.now.addingTimeInterval(-(Self.bootTimeout + 60))
|
||||
let state = SchedulerState(
|
||||
slots: [
|
||||
VMSlot(id: 0, state: .provisioning(since: stuckSince)),
|
||||
VMSlot(id: 1, state: .idle),
|
||||
],
|
||||
dispatchedJobIDs: [1]
|
||||
)
|
||||
|
||||
let (next, actions) = Self.tick(state, [Self.job(1)])
|
||||
|
||||
// Teardown first so the orchestrator frees the slot before reusing it.
|
||||
#expect(actions.count == 2)
|
||||
if case .teardownVM(let slot, let reason) = actions[0] {
|
||||
#expect(slot == 0)
|
||||
#expect(reason.contains("boot timeout"))
|
||||
} else {
|
||||
Issue.record("expected a teardown first, got \(actions[0])")
|
||||
}
|
||||
#expect(actions[1] == .bootVM(slot: 0, jobHint: 1))
|
||||
#expect(next.slots[0].state == .provisioning(since: Self.now))
|
||||
#expect(next.dispatchedJobIDs == [1])
|
||||
}
|
||||
|
||||
@Test("a boot inside its timeout is left alone")
|
||||
func youngBootIsNotTornDown() {
|
||||
let state = SchedulerState(
|
||||
slots: [VMSlot(id: 0, state: .provisioning(since: Self.now.addingTimeInterval(-10)))],
|
||||
dispatchedJobIDs: [1]
|
||||
)
|
||||
let (next, actions) = Self.tick(state, [Self.job(1)])
|
||||
#expect(actions.isEmpty)
|
||||
#expect(next == state)
|
||||
}
|
||||
|
||||
@Test("a run that overshoots the job timeout is torn down")
|
||||
func jobTimeoutTearsDownARunningSlot() {
|
||||
let startedAt = Self.now.addingTimeInterval(-(Self.jobTimeout + 300))
|
||||
let state = SchedulerState(
|
||||
slots: [
|
||||
VMSlot(id: 0, state: .running(jobHint: 7, since: startedAt)),
|
||||
VMSlot(id: 1, state: .idle),
|
||||
],
|
||||
dispatchedJobIDs: [7]
|
||||
)
|
||||
|
||||
let (next, actions) = Self.tick(state, [])
|
||||
|
||||
#expect(actions.count == 1)
|
||||
if case .teardownVM(let slot, let reason) = actions[0] {
|
||||
#expect(slot == 0)
|
||||
#expect(reason.contains("job timeout"))
|
||||
} else {
|
||||
Issue.record("expected a teardown, got \(actions[0])")
|
||||
}
|
||||
#expect(next.slots[0].state == .idle)
|
||||
#expect(next.dispatchedJobIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("a run inside its timeout is left alone")
|
||||
func youngRunIsNotTornDown() {
|
||||
let state = SchedulerState(
|
||||
slots: [VMSlot(id: 0, state: .running(jobHint: 7, since: Self.now.addingTimeInterval(-60)))]
|
||||
)
|
||||
let (next, actions) = Self.tick(state, [])
|
||||
#expect(actions.isEmpty)
|
||||
#expect(next == state)
|
||||
}
|
||||
|
||||
@Test("both slots can time out on the same tick")
|
||||
func bothSlotsCanTimeOutTogether() {
|
||||
let state = SchedulerState(
|
||||
slots: [
|
||||
VMSlot(id: 0, state: .provisioning(since: Self.now.addingTimeInterval(-1000))),
|
||||
VMSlot(id: 1, state: .running(jobHint: 9, since: Self.now.addingTimeInterval(-100_000))),
|
||||
],
|
||||
dispatchedJobIDs: [9]
|
||||
)
|
||||
let (next, actions) = Self.tick(state, [])
|
||||
#expect(actions.count == 2)
|
||||
#expect(next.occupiedSlots.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Transitions
|
||||
|
||||
@Test("the mark* helpers move a slot through its lifecycle")
|
||||
func slotTransitions() {
|
||||
var state = SchedulerState(slotCount: 2)
|
||||
|
||||
state = SchedulerCore.markProvisioning(state: state, slot: 1, jobHint: 42, now: Self.now)
|
||||
#expect(state.slots[1].state == .provisioning(since: Self.now))
|
||||
#expect(state.dispatchedJobIDs == [42])
|
||||
|
||||
let live = Self.now.addingTimeInterval(90)
|
||||
state = SchedulerCore.markRunning(state: state, slot: 1, jobHint: 42, now: live)
|
||||
#expect(state.slots[1].state == .running(jobHint: 42, since: live))
|
||||
|
||||
state = SchedulerCore.markIdle(state: state, slot: 1)
|
||||
#expect(state.slots[1].state == .idle)
|
||||
#expect(state.occupiedSlots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("markRunning keeps an existing hint and tolerates an unknown slot")
|
||||
func markRunningIsForgiving() {
|
||||
var state = SchedulerState(slotCount: 1)
|
||||
state = SchedulerCore.markRunning(state: state, slot: 0, jobHint: 5, now: Self.now)
|
||||
|
||||
let later = Self.now.addingTimeInterval(30)
|
||||
let carried = SchedulerCore.markRunning(state: state, slot: 0, now: later)
|
||||
#expect(carried.slots[0].state == .running(jobHint: 5, since: later))
|
||||
|
||||
// A slot id we do not own is ignored rather than trapping.
|
||||
#expect(SchedulerCore.markRunning(state: state, slot: 99, now: later) == state)
|
||||
#expect(SchedulerCore.markIdle(state: state, slot: 99) == state)
|
||||
}
|
||||
|
||||
// MARK: - Purity
|
||||
|
||||
@Test("planning is deterministic and leaves its input untouched")
|
||||
func planningIsPure() {
|
||||
let state = SchedulerState(slotCount: 2)
|
||||
let queue = [Self.job(1), Self.job(2)]
|
||||
|
||||
let (firstState, firstActions) = Self.tick(state, queue)
|
||||
let (secondState, secondActions) = Self.tick(state, queue)
|
||||
|
||||
#expect(firstState == secondState)
|
||||
#expect(firstActions == secondActions)
|
||||
// The value passed in is unchanged — `plan` returns a new state.
|
||||
#expect(state.occupiedSlots.isEmpty)
|
||||
#expect(state.dispatchedJobIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("the plan never contains an explicit no-op action")
|
||||
func noOpIsAnEmptyPlan() {
|
||||
let (_, actions) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
|
||||
#expect(!actions.contains(.none))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user