fabro/apps/fabro-web/app/lib/run-actions.test.ts
2026-07-24 22:50:00 -04:00

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");
});
});