Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user