Files
kandev/apps/web/e2e/tests/settings/host-shell-pty.spec.ts
T
2026-08-06 11:16:29 +01:00

154 lines
5.7 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
type HostShellSession = {
session_id: string;
agent_id: string;
cmd: string[];
running: boolean;
};
type HostShellStream = {
ws: WebSocket;
getOutput: () => string;
hasExited: () => boolean;
};
async function startHostShell(baseUrl: string, clientId?: string): Promise<HostShellSession> {
const response = await fetch(`${baseUrl}/api/v1/host-shell/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cols: 80, rows: 24, ...(clientId ? { client_id: clientId } : {}) }),
});
expect(response.status).toBe(200);
return (await response.json()) as HostShellSession;
}
async function stopHostShell(baseUrl: string, sessionId: string): Promise<void> {
await fetch(`${baseUrl}/api/v1/agent-login/sessions/${sessionId}/stop`, {
method: "POST",
});
}
async function openHostShellStream(baseUrl: string, sessionId: string): Promise<HostShellStream> {
const wsUrl = baseUrl.replace(/^http/, "ws") + `/api/v1/agent-login/sessions/${sessionId}/stream`;
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
let output = "";
let exited = false;
ws.addEventListener("message", (event) => {
if (typeof event.data === "string") {
try {
if ((JSON.parse(event.data) as { type?: string }).type === "exit") exited = true;
} catch {
// Ignore non-JSON control frames.
}
return;
}
output += new TextDecoder().decode(new Uint8Array(event.data as ArrayBuffer));
});
await new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true });
ws.addEventListener("error", () => reject(new Error("host shell websocket error")), {
once: true,
});
});
return { ws, getOutput: () => output, hasExited: () => exited };
}
async function waitFor(check: () => boolean, timeoutMs: number, label: string) {
await expect.poll(check, { timeout: timeoutMs, message: `Wait for ${label}` }).toBe(true);
}
test.describe("host shell PTY", () => {
test("starts, streams a command, and stops with the legacy singleton identity", async ({
backend,
}) => {
test.setTimeout(15_000);
const session = await startHostShell(backend.baseUrl);
try {
expect(session.session_id).toBeTruthy();
expect(session.agent_id).toBe("_host_shell");
expect(session.running).toBe(true);
expect(session.cmd.length).toBeGreaterThan(0);
const stream = await openHostShellStream(backend.baseUrl, session.session_id);
try {
stream.ws.send(new TextEncoder().encode("echo HELLO_FROM_HOST_SHELL\n"));
await waitFor(
() => stream.getOutput().includes("HELLO_FROM_HOST_SHELL"),
8_000,
"shell echo",
);
await stopHostShell(backend.baseUrl, session.session_id);
await waitFor(() => stream.hasExited(), 5_000, "legacy shell exit");
} finally {
stream.ws.close();
}
} finally {
await stopHostShell(backend.baseUrl, session.session_id);
}
});
test("is idempotent per client while distinct clients get independent PTYs", async ({
backend,
}) => {
test.setTimeout(20_000);
const clientA = "11111111-1111-4111-8111-111111111111";
const clientB = "22222222-2222-4222-8222-222222222222";
const first = await startHostShell(backend.baseUrl, clientA);
let otherClient: HostShellSession | undefined;
let streamA: HostShellStream | undefined;
let streamB: HostShellStream | undefined;
try {
const sameClient = await startHostShell(backend.baseUrl, clientA);
otherClient = await startHostShell(backend.baseUrl, clientB);
expect(sameClient.session_id).toBe(first.session_id);
expect(otherClient.session_id).not.toBe(first.session_id);
expect(otherClient.agent_id).toBe("_host_shell");
streamA = await openHostShellStream(backend.baseUrl, first.session_id);
streamB = await openHostShellStream(backend.baseUrl, otherClient.session_id);
streamA.ws.send(new TextEncoder().encode("echo CLIENT_A_MARKER\n"));
streamB.ws.send(new TextEncoder().encode("echo CLIENT_B_MARKER\n"));
await waitFor(() => streamA!.getOutput().includes("CLIENT_A_MARKER"), 8_000, "client A");
await waitFor(() => streamB!.getOutput().includes("CLIENT_B_MARKER"), 8_000, "client B");
expect(streamA.getOutput()).not.toContain("CLIENT_B_MARKER");
expect(streamB.getOutput()).not.toContain("CLIENT_A_MARKER");
await stopHostShell(backend.baseUrl, first.session_id);
await waitFor(() => streamA!.hasExited(), 5_000, "client A exit");
streamB.ws.send(new TextEncoder().encode("echo CLIENT_B_AFTER_A_STOP\n"));
await waitFor(
() => streamB!.getOutput().includes("CLIENT_B_AFTER_A_STOP"),
8_000,
"client B after A stop",
);
expect(streamB.hasExited()).toBe(false);
} finally {
streamA?.ws.close();
streamB?.ws.close();
await stopHostShell(backend.baseUrl, first.session_id);
if (otherClient) await stopHostShell(backend.baseUrl, otherClient.session_id);
}
});
test("rejects malformed client IDs and preserves omitted-client compatibility", async ({
backend,
}) => {
const invalid = await fetch(`${backend.baseUrl}/api/v1/host-shell/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cols: 80, rows: 24, client_id: "not-a-uuid" }),
});
expect(invalid.status).toBe(400);
const first = await startHostShell(backend.baseUrl);
try {
const second = await startHostShell(backend.baseUrl);
expect(second.session_id).toBe(first.session_id);
} finally {
await stopHostShell(backend.baseUrl, first.session_id);
}
});
});