Files
kandev/apps/web/e2e/tests/settings/agent-install-streaming.spec.ts
T

179 lines
6.7 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
/**
* End-to-end coverage for the streaming agent install flow:
*
* POST /api/v1/agent-install/<name> enqueues a job, returns job_id
* WS /api/v1/ws broadcasts agent.install.*
* GET /api/v1/agent-install/jobs/:id snapshot for late joiners
*
* mock-agent has a deterministic InstallScript (echo + && chain) so this
* test exercises the real exec.Cmd path without depending on npm.
*/
test.describe("agent install streaming", () => {
test("POST returns a job_id, WS streams started+output+finished, GET reflects success", async ({
backend,
}) => {
test.setTimeout(15_000);
const ws = await openGatewayWS(backend.baseUrl);
const events: Array<{ action: string; payload: unknown }> = [];
ws.addEventListener("message", (ev) => {
try {
const msg = JSON.parse(ev.data as string);
if (typeof msg.action === "string" && msg.action.startsWith("agent.install.")) {
events.push({ action: msg.action, payload: msg.payload });
}
} catch {
// ignore
}
});
const enqueueRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, {
method: "POST",
});
expect(enqueueRes.status).toBe(202);
const job = (await enqueueRes.json()) as { job_id: string; status: string };
expect(job.job_id).toBeTruthy();
// Wait for the finished event (or timeout).
await waitFor(
() => events.some((e) => e.action === "agent.install.finished"),
8_000,
"agent.install.finished",
);
ws.close();
const actions = events.map((e) => e.action);
expect(actions).toContain("agent.install.started");
expect(actions).toContain("agent.install.finished");
const finishedEvent = events.find((e) => e.action === "agent.install.finished");
expect(finishedEvent).toBeTruthy();
const finishedPayload = finishedEvent!.payload as {
job_id: string;
status: string;
output?: string;
};
expect(finishedPayload.job_id).toBe(job.job_id);
expect(finishedPayload.status).toBe("succeeded");
expect(finishedPayload.output ?? "").toContain("mock-install: done");
// GET snapshot reflects success.
const snapRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/jobs/${job.job_id}`);
expect(snapRes.status).toBe(200);
const snap = (await snapRes.json()) as { status: string };
expect(snap.status).toBe("succeeded");
});
test("POST while a job is running returns the same job_id (idempotent)", async ({ backend }) => {
test.setTimeout(15_000);
// Note: mock-agent's script is fast (~10ms), so the second POST usually
// races to land before the first finishes. We accept either outcome:
// same job_id (still running) OR different job_id with first already in
// retention. The contract under test is: no duplicate concurrent runs.
const first = (await (
await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { method: "POST" })
).json()) as { job_id: string };
const second = (await (
await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { method: "POST" })
).json()) as { job_id: string };
// If both raced before finish they must share IDs; if first already
// exited then second's ID is allowed to differ.
if (first.job_id !== second.job_id) {
const firstSnap = await fetch(
`${backend.baseUrl}/api/v1/agent-install/jobs/${first.job_id}`,
).then((r) => r.json() as Promise<{ status: string }>);
expect(["succeeded", "failed"]).toContain(firstSnap.status);
}
});
test("GET /agent-install/jobs lists recent jobs for rehydration", async ({ backend }) => {
test.setTimeout(15_000);
// Trigger an install so there's at least one job in retention.
const enqueueRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, {
method: "POST",
});
const job = (await enqueueRes.json()) as { job_id: string };
// Wait for it to finish (mock script is fast).
let finalStatus = "";
const deadline = Date.now() + 8_000;
while (Date.now() < deadline) {
const snap = (await (
await fetch(`${backend.baseUrl}/api/v1/agent-install/jobs/${job.job_id}`)
).json()) as { status: string };
if (snap.status === "succeeded" || snap.status === "failed") {
finalStatus = snap.status;
break;
}
await new Promise((r) => setTimeout(r, 50));
}
expect(["succeeded", "failed"]).toContain(finalStatus);
const listRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/jobs`);
expect(listRes.status).toBe(200);
const list = (await listRes.json()) as {
jobs: Array<{ job_id: string; agent_name: string; status: string }>;
};
expect(Array.isArray(list.jobs)).toBe(true);
const found = list.jobs.find((j) => j.job_id === job.job_id);
expect(found).toBeTruthy();
expect(found!.agent_name).toBe("mock-agent");
});
test("after install probe completes, agent.available.updated is broadcast", async ({
backend,
}) => {
test.setTimeout(20_000);
// Open the gateway WS *before* triggering the install so we don't miss
// the broadcast.
const ws = await openGatewayWS(backend.baseUrl);
let availableUpdates = 0;
ws.addEventListener("message", (ev) => {
try {
const msg = JSON.parse(ev.data as string);
if (msg.action === "agent.available.updated") availableUpdates++;
} catch {
// ignore
}
});
await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { method: "POST" });
// The controller fires a `Refresh` after install and then broadcasts
// `agent.available.updated`. The mock-agent's host-utility probe may
// succeed or fail (it isn't a real ACP agent under e2e), but the
// broadcast fires either way.
await waitFor(() => availableUpdates >= 1, 15_000, "agent.available.updated");
ws.close();
});
});
/** Open a WebSocket to the gateway and resolve when the connection is up. */
async function openGatewayWS(baseUrl: string): Promise<WebSocket> {
const wsUrl = baseUrl.replace(/^http/, "ws") + "/ws";
const ws = new WebSocket(wsUrl);
await new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true });
ws.addEventListener("error", () => reject(new Error("ws error")), { once: true });
});
return ws;
}
async function waitFor(check: () => boolean, timeoutMs: number, label: string) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error(`Timed out waiting for ${label}`);
}