102 lines
4.0 KiB
TypeScript
102 lines
4.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { CompactRunner, CompactRunnerJob } from "../src/services/activityRuntime.js";
|
|
import { evaluateRunnerClaimHealth } from "../src/services/runnerClaimHealth.js";
|
|
|
|
const runner = (input: Partial<CompactRunner> = {}): CompactRunner => ({
|
|
id: 1,
|
|
name: "validation-1",
|
|
status: "online",
|
|
busy: false,
|
|
disabled: false,
|
|
labels: ["linux-validation"],
|
|
...input,
|
|
});
|
|
|
|
const job = (input: Partial<CompactRunnerJob> = {}): CompactRunnerJob => ({
|
|
id: 99,
|
|
runId: 9,
|
|
name: "full",
|
|
repository: "Jens/projectbrain",
|
|
branch: "feature",
|
|
status: "queued",
|
|
queueState: "runnable",
|
|
labels: ["linux-validation"],
|
|
completedSteps: 0,
|
|
totalSteps: 1,
|
|
url: "https://gitea.example/jobs/99",
|
|
createdAt: "2026-09-30T00:00:00.000Z",
|
|
runnableObservedSince: "2026-09-30T00:00:00.000Z",
|
|
claimableObservedSince: "2026-09-30T00:00:00.000Z",
|
|
...input,
|
|
});
|
|
|
|
describe("runner claim health", () => {
|
|
const now = Date.parse("2026-09-30T00:05:00.000Z");
|
|
|
|
it("marks an old queued job with a compatible idle runner as polling-stalled without inventing a restart action", () => {
|
|
const health = evaluateRunnerClaimHealth({ runners: [runner()], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now, stallSeconds: 120 });
|
|
expect(health).toMatchObject({
|
|
status: "stalled",
|
|
recoveryOwner: "appops-runner-fleet",
|
|
recoveryPolicy: "controller-owned",
|
|
counts: { pollingStalled: 1 },
|
|
oldestWaitSeconds: 300,
|
|
oldestClaimableWaitSeconds: 300,
|
|
});
|
|
expect(health.jobs[0]).toMatchObject({ state: "polling-stalled", compatibleIdleRunnerNames: ["validation-1"] });
|
|
expect(JSON.stringify(health)).not.toContain("container.restart");
|
|
});
|
|
|
|
it("classifies a busy compatible runner as capacity-wait", () => {
|
|
const health = evaluateRunnerClaimHealth({ runners: [runner({ busy: true })], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now });
|
|
expect(health.jobs[0]).toMatchObject({ state: "capacity-wait", waitSeconds: 300, claimableWaitSeconds: 0 });
|
|
expect(health.oldestClaimableWaitSeconds).toBe(0);
|
|
});
|
|
|
|
it("reports a label block when no online runner can satisfy the job", () => {
|
|
const health = evaluateRunnerClaimHealth({ runners: [runner({ labels: ["security"] })], jobs: [job()], generatedAt: "2026-09-30T00:05:00.000Z" }, { now });
|
|
expect(health.status).toBe("degraded");
|
|
expect(health.counts.labelBlocked).toBe(1);
|
|
});
|
|
|
|
it("reports a polling stall independently of how many compatible idle runners exist", () => {
|
|
const health = evaluateRunnerClaimHealth({
|
|
runners: [runner(), runner({ id: 2, name: "validation-2" })],
|
|
jobs: [job()],
|
|
generatedAt: "2026-09-30T00:05:00.000Z",
|
|
}, { now });
|
|
expect(health.counts.pollingStalled).toBe(1);
|
|
expect(health.jobs[0].compatibleIdleRunnerNames).toEqual(["validation-1", "validation-2"]);
|
|
});
|
|
|
|
it("keeps a newly queued compatible job inside the normal claim grace window", () => {
|
|
const health = evaluateRunnerClaimHealth({
|
|
runners: [runner()],
|
|
jobs: [job({
|
|
createdAt: "2026-09-30T00:04:30.000Z",
|
|
runnableObservedSince: "2026-09-30T00:04:30.000Z",
|
|
claimableObservedSince: "2026-09-30T00:04:30.000Z",
|
|
})],
|
|
generatedAt: "2026-09-30T00:05:00.000Z",
|
|
}, { now, stallSeconds: 120 });
|
|
expect(health.status).toBe("healthy");
|
|
expect(health.jobs[0].state).toBe("claimable");
|
|
});
|
|
|
|
it("reports incomplete coverage rather than healthy when queued jobs were omitted from the scan", () => {
|
|
const health = evaluateRunnerClaimHealth({
|
|
availability: "live",
|
|
runners: [runner()],
|
|
jobs: Array.from({ length: 50 }, (_, index) => job({ id: index + 1, createdAt: "2026-09-30T00:04:30.000Z" })),
|
|
runnableJobScan: { complete: false, observed: 50, total: 60 },
|
|
generatedAt: "2026-09-30T00:05:00.000Z",
|
|
}, { now });
|
|
|
|
expect(health).toMatchObject({
|
|
status: "incomplete",
|
|
coverage: { complete: false, observed: 50, total: 60 },
|
|
counts: { queued: 50, pollingStalled: 0 },
|
|
});
|
|
});
|
|
});
|