feat: v2
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { openExecSession, type ExecWebSocket } from "../../lib/exec-api";
|
||||
|
||||
class FakeSocket implements ExecWebSocket {
|
||||
binaryType: "arraybuffer" | "blob" = "blob";
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
closes: Array<[number | undefined, string | undefined]> = [];
|
||||
private listeners = new Map<string, Set<(event: any) => void>>();
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.closes.push([code, reason]);
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: any) => void) {
|
||||
let values = this.listeners.get(type);
|
||||
if (!values) {
|
||||
values = new Set();
|
||||
this.listeners.set(type, values);
|
||||
}
|
||||
values.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: (event: any) => void) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
emit(type: string, event: any = {}) {
|
||||
if (type === "open") this.readyState = 1;
|
||||
for (const listener of [...(this.listeners.get(type) ?? [])])
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
const session = {
|
||||
token: "secret-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
user: { username: "user", roles: [] },
|
||||
};
|
||||
|
||||
describe("exec WebSocket API", () => {
|
||||
test("authenticates the upgrade and maps protocol byte frames", async () => {
|
||||
const socket = new FakeSocket();
|
||||
let connection:
|
||||
| { url: string; headers: Readonly<Record<string, string>> }
|
||||
| undefined;
|
||||
const opening = openExecSession(
|
||||
"shop/demo",
|
||||
{
|
||||
deployment: "api",
|
||||
command: ["sh", "-c", "echo ok"],
|
||||
tty: true,
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
},
|
||||
new AbortController().signal,
|
||||
{
|
||||
baseUrl: "https://api.test/api/v2",
|
||||
session,
|
||||
socketFactory(url, headers) {
|
||||
connection = { url, headers };
|
||||
return socket;
|
||||
},
|
||||
},
|
||||
);
|
||||
socket.emit("open");
|
||||
const client = await opening;
|
||||
|
||||
expect(connection).toEqual({
|
||||
url: "wss://api.test/api/v2/workspaces/shop%2Fdemo/exec",
|
||||
headers: {
|
||||
authorization: "Bearer secret-token",
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(socket.sent[0]!)).toEqual({
|
||||
type: "start",
|
||||
version: 1,
|
||||
deployment: "api",
|
||||
command: ["sh", "-c", "echo ok"],
|
||||
tty: true,
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
});
|
||||
|
||||
client.sendStdin(new TextEncoder().encode("hello"), true);
|
||||
client.resize(80, 24);
|
||||
|
||||
const iterator = client[Symbol.asyncIterator]();
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({
|
||||
type: "stdout",
|
||||
data: "b3V0",
|
||||
encoding: "base64",
|
||||
}),
|
||||
});
|
||||
expect(await iterator.next()).toEqual({
|
||||
done: false,
|
||||
value: { type: "stdout", data: new TextEncoder().encode("out") },
|
||||
});
|
||||
|
||||
expect(JSON.parse(socket.sent[1]!)).toEqual({
|
||||
type: "stdin",
|
||||
data: "aGVsbG8=",
|
||||
encoding: "base64",
|
||||
eof: true,
|
||||
});
|
||||
expect(JSON.parse(socket.sent[2]!)).toEqual({
|
||||
type: "resize",
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ type: "exit", exitCode: 7 }),
|
||||
});
|
||||
expect((await iterator.next()).value).toEqual({
|
||||
type: "exit",
|
||||
exitCode: 7,
|
||||
});
|
||||
socket.emit("close");
|
||||
expect((await iterator.next()).done).toBe(true);
|
||||
});
|
||||
|
||||
test("closes an active connection when cancelled", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const controller = new AbortController();
|
||||
const opening = openExecSession(
|
||||
"shop",
|
||||
{ deployment: "api", command: ["sh"], tty: false },
|
||||
controller.signal,
|
||||
{ session, socketFactory: () => socket },
|
||||
);
|
||||
socket.emit("open");
|
||||
const client = await opening;
|
||||
const next = client[Symbol.asyncIterator]().next();
|
||||
controller.abort();
|
||||
expect((await next).done).toBe(true);
|
||||
expect(socket.closes).toContainEqual([1000, "aborted"]);
|
||||
});
|
||||
|
||||
test("rejects malformed server frames and closes with protocol error", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const opening = openExecSession(
|
||||
"shop",
|
||||
{ deployment: "api", command: ["sh"], tty: false },
|
||||
new AbortController().signal,
|
||||
{ session, socketFactory: () => socket },
|
||||
);
|
||||
socket.emit("open");
|
||||
const client = await opening;
|
||||
const next = client[Symbol.asyncIterator]().next();
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ type: "stdout", data: 1 }),
|
||||
});
|
||||
await expect(next).rejects.toThrow("Invalid exec output frame");
|
||||
expect(socket.closes).toContainEqual([1002, "invalid frame"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user