mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
490 lines
15 KiB
TypeScript
490 lines
15 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import type { AxiosAdapter } from "axios";
|
|
import type {
|
|
BatchDeleteRunsResponse,
|
|
BatchRunLifecycleResponse,
|
|
Run,
|
|
RunStatus,
|
|
} from "@qltysh/fabro-api-client";
|
|
|
|
import {
|
|
archiveRun,
|
|
archiveRuns,
|
|
canArchive,
|
|
canApprove,
|
|
canCancel,
|
|
canRetry,
|
|
canUnarchive,
|
|
cancellationActionLabel,
|
|
cancellationSuccessMessage,
|
|
cancelRun,
|
|
deleteRuns,
|
|
isTerminalCancelledRun,
|
|
isTerminalRunStatus,
|
|
isCancellationPending,
|
|
isCancellationPendingState,
|
|
mapError,
|
|
retryRun,
|
|
unarchiveRun,
|
|
unarchiveRuns,
|
|
} from "./run-actions";
|
|
import { generatedAxios } from "./api-client";
|
|
import { TEST_PRINCIPAL } from "./test-fixtures";
|
|
|
|
type StubResponseInit = {
|
|
status: number;
|
|
body?: unknown;
|
|
statusText?: string;
|
|
};
|
|
|
|
type CapturedRequest = {
|
|
url?: string;
|
|
method?: string;
|
|
data?: unknown;
|
|
};
|
|
|
|
const originalAdapter = generatedAxios.defaults.adapter;
|
|
|
|
function makeRun(status: RunStatus, archived = false): Run {
|
|
return {
|
|
id: "run-1",
|
|
goal: "Fix the build",
|
|
title: "Fix the build",
|
|
workflow: { slug: "fix_build", name: "Fix Build", graph_name: null, node_count: 0, edge_count: 0 },
|
|
automation: null,
|
|
repository: null,
|
|
created_by: TEST_PRINCIPAL,
|
|
origin: { kind: "api" },
|
|
labels: {},
|
|
lifecycle: {
|
|
status,
|
|
approval: null,
|
|
pending_control: null,
|
|
queue_position: null,
|
|
error: null,
|
|
archived,
|
|
archived_at: archived ? "2026-04-20T12:05:00Z" : null,
|
|
},
|
|
sandbox: null,
|
|
models: [],
|
|
source_directory: null,
|
|
timestamps: {
|
|
created_at: "2026-04-20T12:00:00Z",
|
|
started_at: null,
|
|
last_event_at: null,
|
|
completed_at: null,
|
|
},
|
|
billing: null,
|
|
size: "XS",
|
|
diff: null,
|
|
pull_request: null,
|
|
current_question: null,
|
|
superseded_by: null,
|
|
retried_from: null,
|
|
links: { web: null },
|
|
};
|
|
}
|
|
|
|
function stubGeneratedAxiosOnce(init: StubResponseInit): { requests: CapturedRequest[] } {
|
|
const requests: CapturedRequest[] = [];
|
|
generatedAxios.defaults.adapter = (async (config) => {
|
|
requests.push({
|
|
url: config.url,
|
|
method: config.method,
|
|
data: config.data,
|
|
});
|
|
if (init.status >= 400) {
|
|
throw {
|
|
isAxiosError: true,
|
|
message: init.statusText ?? `HTTP ${init.status}`,
|
|
response: {
|
|
status: init.status,
|
|
statusText: init.statusText ?? "",
|
|
data: init.body ?? null,
|
|
headers: {},
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
data: init.body,
|
|
status: init.status,
|
|
statusText: init.statusText ?? "",
|
|
headers: {},
|
|
config,
|
|
};
|
|
}) as AxiosAdapter;
|
|
return { requests };
|
|
}
|
|
|
|
function batchResponse(
|
|
results: BatchRunLifecycleResponse["results"],
|
|
): BatchRunLifecycleResponse {
|
|
const succeeded = results.filter((result) => result.ok).length;
|
|
return {
|
|
results,
|
|
summary: {
|
|
requested: results.length,
|
|
succeeded,
|
|
failed: results.length - succeeded,
|
|
},
|
|
};
|
|
}
|
|
|
|
function batchDeleteResponse(
|
|
results: BatchDeleteRunsResponse["results"],
|
|
): BatchDeleteRunsResponse {
|
|
const succeeded = results.filter((result) => result.ok).length;
|
|
return {
|
|
results,
|
|
summary: {
|
|
requested: results.length,
|
|
succeeded,
|
|
failed: results.length - succeeded,
|
|
},
|
|
};
|
|
}
|
|
|
|
function requestJsonBody(request: CapturedRequest): unknown {
|
|
return typeof request.data === "string" ? JSON.parse(request.data) : request.data;
|
|
}
|
|
|
|
async function expectLifecycleError(
|
|
input: Promise<unknown>,
|
|
): Promise<{ status: number; errors: Array<{ status: string; title: string; detail: string }> }> {
|
|
try {
|
|
await input;
|
|
throw new Error("expected promise to reject");
|
|
} catch (error) {
|
|
return error as { status: number; errors: Array<{ status: string; title: string; detail: string }> };
|
|
}
|
|
}
|
|
|
|
describe("run lifecycle actions", () => {
|
|
afterEach(() => {
|
|
generatedAxios.defaults.adapter = originalAdapter;
|
|
delete (globalThis as { window?: unknown }).window;
|
|
});
|
|
|
|
test("cancelRun parses a 200 response", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 200,
|
|
body: makeRun({ kind: "failed", reason: "cancelled" }),
|
|
});
|
|
|
|
const result = await cancelRun("run-1");
|
|
expect(result.lifecycle.status.kind).toBe("failed");
|
|
if (result.lifecycle.status.kind === "failed") {
|
|
expect(result.lifecycle.status.reason).toBe("cancelled");
|
|
}
|
|
});
|
|
|
|
test("cancelRun parses a 202 response as a pending cancellation", async () => {
|
|
const run = makeRun({ kind: "running" });
|
|
run.lifecycle.pending_control = "cancel";
|
|
stubGeneratedAxiosOnce({
|
|
status: 202,
|
|
body: run,
|
|
});
|
|
|
|
const result = await cancelRun("run-1");
|
|
expect(result.lifecycle.status.kind).toBe("running");
|
|
expect(result.lifecycle.pending_control).toBe("cancel");
|
|
expect(cancellationSuccessMessage(result)).toBe("Cancellation requested.");
|
|
});
|
|
|
|
test("archiveRun parses a 200 response", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 200,
|
|
body: makeRun({ kind: "succeeded", reason: "completed" }, true),
|
|
});
|
|
|
|
const result = await archiveRun("run-1");
|
|
expect(result.lifecycle.status.kind).toBe("succeeded");
|
|
expect(result.lifecycle.archived).toBe(true);
|
|
});
|
|
|
|
test("unarchiveRun parses a 200 response", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 200,
|
|
body: makeRun({ kind: "succeeded", reason: "completed" }),
|
|
});
|
|
|
|
const result = await unarchiveRun("run-1");
|
|
expect(result.lifecycle.status.kind).toBe("succeeded");
|
|
expect(result.lifecycle.archived).toBe(false);
|
|
});
|
|
|
|
test("archiveRuns sends one batch request and parses results", async () => {
|
|
const stub = stubGeneratedAxiosOnce({
|
|
status: 200,
|
|
body: batchResponse([
|
|
{
|
|
run_id: "run-1",
|
|
ok: true,
|
|
outcome: "archived",
|
|
run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-1" },
|
|
},
|
|
{
|
|
run_id: "run-2",
|
|
ok: true,
|
|
outcome: "already_archived",
|
|
run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-2" },
|
|
},
|
|
]),
|
|
});
|
|
|
|
const result = await archiveRuns(["run-1", "run-2"]);
|
|
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]?.method?.toUpperCase()).toBe("POST");
|
|
expect(stub.requests[0]?.url).toBe("/api/v1/runs/archive");
|
|
expect(requestJsonBody(stub.requests[0]!)).toEqual({ run_ids: ["run-1", "run-2"] });
|
|
expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 });
|
|
expect(result.results.map((entry) => entry.outcome)).toEqual(["archived", "already_archived"]);
|
|
});
|
|
|
|
test("unarchiveRuns resolves mixed per-item results without throwing", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 200,
|
|
body: batchResponse([
|
|
{
|
|
run_id: "run-1",
|
|
ok: true,
|
|
outcome: "unarchived",
|
|
run: { ...makeRun({ kind: "succeeded", reason: "completed" }), id: "run-1" },
|
|
},
|
|
{
|
|
run_id: "run-missing",
|
|
ok: false,
|
|
outcome: "not_found",
|
|
error: { status: "404", title: "Not Found", detail: "Run not found." },
|
|
},
|
|
]),
|
|
});
|
|
|
|
const result = await unarchiveRuns(["run-1", "run-missing"]);
|
|
|
|
expect(result.summary).toEqual({ requested: 2, succeeded: 1, failed: 1 });
|
|
expect(result.results[1]?.ok).toBe(false);
|
|
expect(result.results[1]?.error?.status).toBe("404");
|
|
});
|
|
|
|
test("deleteRuns sends one batch request and parses results", async () => {
|
|
const stub = stubGeneratedAxiosOnce({
|
|
status: 200,
|
|
body: batchDeleteResponse([
|
|
{
|
|
run_id: "run-1",
|
|
ok: true,
|
|
outcome: "deleted",
|
|
},
|
|
{
|
|
run_id: "run-missing",
|
|
ok: true,
|
|
outcome: "already_absent",
|
|
},
|
|
]),
|
|
});
|
|
|
|
const result = await deleteRuns(["run-1", "run-missing"]);
|
|
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]?.method?.toUpperCase()).toBe("POST");
|
|
expect(stub.requests[0]?.url).toBe("/api/v1/runs/delete");
|
|
expect(requestJsonBody(stub.requests[0]!)).toEqual({
|
|
run_ids: ["run-1", "run-missing"],
|
|
force: false,
|
|
});
|
|
expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 });
|
|
expect(result.results.map((entry) => entry.outcome)).toEqual(["deleted", "already_absent"]);
|
|
});
|
|
|
|
test("deleteRuns sends force when requested", async () => {
|
|
const stub = stubGeneratedAxiosOnce({
|
|
status: 200,
|
|
body: batchDeleteResponse([
|
|
{
|
|
run_id: "run-1",
|
|
ok: true,
|
|
outcome: "deleted",
|
|
},
|
|
]),
|
|
});
|
|
|
|
await deleteRuns(["run-1"], true);
|
|
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(requestJsonBody(stub.requests[0]!)).toEqual({
|
|
run_ids: ["run-1"],
|
|
force: true,
|
|
});
|
|
});
|
|
|
|
test("deleteRuns preserves request-level error envelopes", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 400,
|
|
body: {
|
|
errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
|
|
},
|
|
});
|
|
|
|
const error = await expectLifecycleError(deleteRuns([]));
|
|
expect(error).toEqual({
|
|
status: 400,
|
|
errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
|
|
});
|
|
});
|
|
|
|
test("batch lifecycle helpers preserve request-level error envelopes", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 400,
|
|
body: {
|
|
errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
|
|
},
|
|
});
|
|
|
|
const error = await expectLifecycleError(archiveRuns([]));
|
|
expect(error).toEqual({
|
|
status: 400,
|
|
errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
|
|
});
|
|
});
|
|
|
|
test("retryRun parses a 201 response", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 201,
|
|
body: {
|
|
...makeRun({ kind: "submitted" }),
|
|
id: "run-2",
|
|
retried_from: "run-1",
|
|
},
|
|
});
|
|
|
|
const result = await retryRun("run-1");
|
|
expect(result.id).toBe("run-2");
|
|
expect(result.retried_from).toBe("run-1");
|
|
expect(result.lifecycle.status.kind).toBe("submitted");
|
|
});
|
|
|
|
test("404 and 409 preserve the parsed error envelope", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 404,
|
|
body: {
|
|
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
|
|
},
|
|
});
|
|
const notFound = await expectLifecycleError(cancelRun("missing-run"));
|
|
expect(notFound).toEqual({
|
|
status: 404,
|
|
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
|
|
});
|
|
|
|
stubGeneratedAxiosOnce({
|
|
status: 409,
|
|
body: {
|
|
errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
|
|
},
|
|
});
|
|
const conflict = await expectLifecycleError(archiveRun("run-1"));
|
|
expect(conflict).toEqual({
|
|
status: 409,
|
|
errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
|
|
});
|
|
});
|
|
|
|
test("non-JSON error bodies fall back to an empty error list", async () => {
|
|
stubGeneratedAxiosOnce({
|
|
status: 409,
|
|
body: "<html>conflict</html>",
|
|
statusText: "Conflict",
|
|
});
|
|
|
|
const error = await expectLifecycleError(unarchiveRun("run-1"));
|
|
expect(error).toEqual({ status: 409, errors: [] });
|
|
});
|
|
|
|
test("mapError returns user-facing copy for lifecycle conflicts", () => {
|
|
expect(mapError({ status: 409, errors: [] }, "cancel")).toBe("This run can no longer be cancelled.");
|
|
expect(mapError({ status: 409, errors: [] }, "approve")).toBe("This run is no longer pending approval.");
|
|
expect(mapError({ status: 409, errors: [] }, "deny")).toBe("This run is no longer pending approval.");
|
|
expect(mapError({ status: 409, errors: [] }, "archive")).toBe("Only terminal runs can be archived.");
|
|
expect(mapError({ status: 409, errors: [] }, "unarchive")).toBe("Active runs can't be unarchived.");
|
|
});
|
|
|
|
test("status predicates align with the documented run statuses", () => {
|
|
expect(canCancel("submitted")).toBe(true);
|
|
expect(canCancel("pending")).toBe(true);
|
|
expect(canCancel("runnable")).toBe(true);
|
|
expect(canCancel("starting")).toBe(true);
|
|
expect(canCancel("running")).toBe(true);
|
|
expect(canCancel("paused")).toBe(true);
|
|
expect(canCancel("blocked")).toBe(true);
|
|
expect(canCancel("archived")).toBe(false);
|
|
|
|
expect(canArchive("succeeded")).toBe(true);
|
|
expect(canArchive("failed")).toBe(true);
|
|
expect(canArchive("dead")).toBe(true);
|
|
expect(canArchive("archived")).toBe(false);
|
|
expect(isTerminalRunStatus("succeeded")).toBe(true);
|
|
expect(isTerminalRunStatus("running")).toBe(false);
|
|
|
|
expect(canUnarchive("archived")).toBe(true);
|
|
expect(canUnarchive("failed")).toBe(false);
|
|
});
|
|
|
|
test("approval predicate requires pending status and pending approval state", () => {
|
|
expect(canApprove({
|
|
...makeRun({ kind: "pending", reason: "approval_required" }),
|
|
lifecycle: {
|
|
...makeRun({ kind: "pending", reason: "approval_required" }).lifecycle,
|
|
approval: {
|
|
state: "pending",
|
|
requested_at: "2026-05-23T12:00:00Z",
|
|
decided_at: null,
|
|
denial_reason: null,
|
|
},
|
|
},
|
|
})).toBe(true);
|
|
expect(canApprove(makeRun({ kind: "pending", reason: "approval_required" }))).toBe(false);
|
|
expect(canApprove(makeRun({ kind: "runnable" }))).toBe(false);
|
|
});
|
|
|
|
test("canRetry allows terminal runs except archived runs", () => {
|
|
expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }))).toBe(true);
|
|
expect(canRetry(makeRun({ kind: "dead" }))).toBe(true);
|
|
expect(canRetry(makeRun({ kind: "failed", reason: "cancelled" }))).toBe(true);
|
|
expect(canRetry(makeRun({ kind: "succeeded", reason: "completed" }))).toBe(true);
|
|
expect(canRetry(makeRun({ kind: "running" }))).toBe(false);
|
|
expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }, true))).toBe(false);
|
|
expect(canRetry(makeRun({ kind: "succeeded", reason: "completed" }, true))).toBe(false);
|
|
});
|
|
|
|
test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => {
|
|
expect(
|
|
isTerminalCancelledRun(makeRun({ kind: "failed", reason: "cancelled" })),
|
|
).toBe(true);
|
|
expect(
|
|
isTerminalCancelledRun(
|
|
makeRun({ kind: "running" }, false),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("cancellation pending combines durable state with the active mutation", () => {
|
|
const running = makeRun({ kind: "running" });
|
|
expect(isCancellationPending(running)).toBe(false);
|
|
expect(isCancellationPending(running, true)).toBe(true);
|
|
|
|
running.lifecycle.pending_control = "cancel";
|
|
expect(isCancellationPending(running)).toBe(true);
|
|
|
|
const cancelled = makeRun({ kind: "failed", reason: "cancelled" });
|
|
cancelled.lifecycle.pending_control = "cancel";
|
|
expect(isCancellationPending(cancelled, true)).toBe(false);
|
|
expect(cancellationSuccessMessage(cancelled)).toBe("Run cancelled.");
|
|
expect(isCancellationPendingState("running", "cancel")).toBe(true);
|
|
expect(isCancellationPendingState("failed", "cancel", true)).toBe(false);
|
|
expect(cancellationActionLabel(true)).toBe("Cancelling…");
|
|
expect(cancellationActionLabel(false)).toBe("Cancel");
|
|
});
|
|
});
|