Merge agent-management and agent MCP into a unified server with shared utilities. Remove the voice-mcp-bridge in favor of direct MCP tool registration. Add schedule, terminal, and worktree management tools to the MCP server for CLI parity. Include e2e parity tests.
720 lines
20 KiB
TypeScript
720 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import {
|
|
asUint8Array,
|
|
decodeTerminalStreamFrame,
|
|
encodeTerminalStreamFrame,
|
|
TerminalStreamOpcode,
|
|
} from "../shared/terminal-stream-protocol.js";
|
|
|
|
const wsModuleMock = vi.hoisted(() => {
|
|
class MockWebSocketServer {
|
|
static instances: MockWebSocketServer[] = [];
|
|
readonly handlers = new Map<string, (...args: any[]) => void>();
|
|
|
|
constructor(_options: unknown) {
|
|
MockWebSocketServer.instances.push(this);
|
|
}
|
|
|
|
on(event: string, handler: (...args: any[]) => void) {
|
|
this.handlers.set(event, handler);
|
|
return this;
|
|
}
|
|
|
|
close() {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
return { MockWebSocketServer };
|
|
});
|
|
|
|
const sessionMock = vi.hoisted(() => {
|
|
const instances: MockSession[] = [];
|
|
|
|
class MockSession {
|
|
cleanup = vi.fn(async () => {});
|
|
handleMessage = vi.fn(async () => {});
|
|
handleBinaryFrame = vi.fn((_frame: unknown) => {});
|
|
getClientActivity = vi.fn(() => null);
|
|
resetPeakInflight = vi.fn(() => {});
|
|
getRuntimeMetrics = vi.fn(() => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
terminalDirectorySubscriptionCount: 0,
|
|
terminalSubscriptionCount: 0,
|
|
inflightRequests: 0,
|
|
peakInflightRequests: 0,
|
|
}));
|
|
readonly args: Record<string, unknown>;
|
|
|
|
constructor(args: Record<string, unknown>) {
|
|
this.args = args;
|
|
instances.push(this);
|
|
}
|
|
}
|
|
|
|
return { MockSession, instances };
|
|
});
|
|
|
|
vi.mock("ws", () => ({
|
|
WebSocketServer: wsModuleMock.MockWebSocketServer,
|
|
}));
|
|
|
|
vi.mock("./session.js", () => ({
|
|
Session: sessionMock.MockSession,
|
|
}));
|
|
|
|
vi.mock("./push/token-store.js", () => ({
|
|
PushTokenStore: class {
|
|
getAllTokens(): string[] {
|
|
return [];
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock("./push/push-service.js", () => ({
|
|
PushService: class {
|
|
async sendPush(): Promise<void> {
|
|
// no-op
|
|
}
|
|
},
|
|
}));
|
|
|
|
import { VoiceAssistantWebSocketServer } from "./websocket-server";
|
|
import { parseServerInfoStatusPayload } from "./messages.js";
|
|
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
|
|
|
const TEST_DAEMON_VERSION = "1.2.3-test";
|
|
|
|
class MockSocket {
|
|
readyState = 1;
|
|
bufferedAmount = 0;
|
|
sent: unknown[] = [];
|
|
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
|
|
|
on(event: "message" | "close" | "error", listener: (...args: any[]) => void): void {
|
|
const handlers = this.listeners.get(event) ?? [];
|
|
handlers.push(listener);
|
|
this.listeners.set(event, handlers);
|
|
}
|
|
|
|
once(event: "close" | "error", listener: (...args: any[]) => void): void {
|
|
const wrapped = (...args: any[]) => {
|
|
this.off(event, wrapped);
|
|
listener(...args);
|
|
};
|
|
this.on(event, wrapped);
|
|
}
|
|
|
|
send(data: unknown): void {
|
|
this.sent.push(data);
|
|
}
|
|
|
|
close(code?: number, reason?: string): void {
|
|
this.readyState = 3;
|
|
this.emit("close", code ?? 1000, reason ?? "");
|
|
}
|
|
|
|
emit(event: "message" | "close" | "error", ...args: any[]): void {
|
|
const handlers = this.listeners.get(event) ?? [];
|
|
for (const handler of [...handlers]) {
|
|
handler(...args);
|
|
}
|
|
}
|
|
|
|
private off(event: "close" | "error", listener: (...args: any[]) => void): void {
|
|
const handlers = this.listeners.get(event) ?? [];
|
|
this.listeners.set(
|
|
event,
|
|
handlers.filter((handler) => handler !== listener),
|
|
);
|
|
}
|
|
}
|
|
|
|
function createLogger() {
|
|
const logger = {
|
|
child: vi.fn(() => logger),
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
return logger;
|
|
}
|
|
|
|
function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | null }) {
|
|
const speechReadiness = options?.speechReadiness ?? null;
|
|
return new VoiceAssistantWebSocketServer(
|
|
{} as any,
|
|
createLogger() as any,
|
|
"srv_test",
|
|
{
|
|
setAgentAttentionCallback: vi.fn(),
|
|
getAgent: vi.fn(() => null),
|
|
getMetricsSnapshot: vi.fn(() => ({
|
|
totalAgents: 0,
|
|
idleAgents: 0,
|
|
runningAgents: 0,
|
|
pendingPermissionAgents: 0,
|
|
erroredAgents: 0,
|
|
})),
|
|
} as any,
|
|
{} as any,
|
|
{} as any,
|
|
"/tmp/paseo-test",
|
|
null,
|
|
{ allowedOrigins: new Set() },
|
|
speechReadiness
|
|
? {
|
|
getReadiness: () => speechReadiness,
|
|
onReadinessChange: vi.fn(() => () => {}),
|
|
}
|
|
: undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
TEST_DAEMON_VERSION,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{
|
|
subscribe: vi.fn(),
|
|
scheduleRefreshForCwd: vi.fn(),
|
|
getMetrics: vi.fn(() => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
})),
|
|
dispose: vi.fn(),
|
|
} as any,
|
|
);
|
|
}
|
|
|
|
function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
|
|
return {
|
|
generatedAt: "2026-02-14T00:00:00.000Z",
|
|
requiredLocalModelIds: [],
|
|
missingLocalModelIds: [],
|
|
download: {
|
|
inProgress: false,
|
|
error: null,
|
|
},
|
|
dictation: {
|
|
enabled: true,
|
|
available: true,
|
|
reasonCode: "ready",
|
|
message: "Dictation is ready.",
|
|
retryable: false,
|
|
missingModelIds: [],
|
|
},
|
|
realtimeVoice: {
|
|
enabled: true,
|
|
available: true,
|
|
reasonCode: "ready",
|
|
message: "Realtime voice is ready.",
|
|
retryable: false,
|
|
missingModelIds: [],
|
|
},
|
|
voiceFeature: {
|
|
enabled: true,
|
|
available: true,
|
|
reasonCode: "ready",
|
|
message: "Voice features are ready.",
|
|
retryable: false,
|
|
missingModelIds: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnapshot {
|
|
return {
|
|
generatedAt: "2026-02-14T00:00:00.000Z",
|
|
requiredLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
|
|
missingLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
|
|
download: {
|
|
inProgress: true,
|
|
error: null,
|
|
},
|
|
dictation: {
|
|
enabled: true,
|
|
available: false,
|
|
reasonCode: "stt_unavailable",
|
|
message: "Dictation is unavailable: speech-to-text service is not ready.",
|
|
retryable: false,
|
|
missingModelIds: [],
|
|
},
|
|
realtimeVoice: {
|
|
enabled: true,
|
|
available: false,
|
|
reasonCode: "stt_unavailable",
|
|
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
|
|
retryable: false,
|
|
missingModelIds: [],
|
|
},
|
|
voiceFeature: {
|
|
enabled: true,
|
|
available: false,
|
|
reasonCode: "model_download_in_progress",
|
|
message:
|
|
"Voice features are unavailable while models download in the background (sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20).",
|
|
retryable: true,
|
|
missingModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
|
|
},
|
|
};
|
|
}
|
|
|
|
function createHelloMessage(clientId: string) {
|
|
return {
|
|
type: "hello" as const,
|
|
clientId,
|
|
clientType: "cli" as const,
|
|
protocolVersion: 1,
|
|
};
|
|
}
|
|
|
|
function createDirectRequest() {
|
|
return {
|
|
headers: {
|
|
host: "localhost:6767",
|
|
origin: "http://localhost:6767",
|
|
"user-agent": "vitest",
|
|
},
|
|
socket: {
|
|
remoteAddress: "127.0.0.1",
|
|
},
|
|
url: "/ws",
|
|
};
|
|
}
|
|
|
|
async function attachRelayAndHello(params: {
|
|
server: VoiceAssistantWebSocketServer;
|
|
socket: MockSocket;
|
|
clientId: string;
|
|
}) {
|
|
await params.server.attachExternalSocket(params.socket, { transport: "relay" });
|
|
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
|
await Promise.resolve();
|
|
expect(params.socket.sent.length).toBeGreaterThan(0);
|
|
const envelope = JSON.parse(params.socket.sent[0] as string) as {
|
|
type?: unknown;
|
|
message?: { type?: unknown; payload?: unknown };
|
|
};
|
|
expect(envelope.type).toBe("session");
|
|
const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
|
|
expect(envelope.message?.type).toBe("status");
|
|
expect(serverInfo).not.toBeNull();
|
|
return serverInfo!;
|
|
}
|
|
|
|
async function attachDirectAndHello(params: {
|
|
server: VoiceAssistantWebSocketServer;
|
|
socket: MockSocket;
|
|
clientId: string;
|
|
}) {
|
|
await (params.server as any).attachSocket(params.socket, createDirectRequest());
|
|
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
|
await Promise.resolve();
|
|
expect(params.socket.sent.length).toBeGreaterThan(0);
|
|
const envelope = JSON.parse(params.socket.sent[0] as string) as {
|
|
type?: unknown;
|
|
message?: { type?: unknown; payload?: unknown };
|
|
};
|
|
expect(envelope.type).toBe("session");
|
|
const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
|
|
expect(envelope.message?.type).toBe("status");
|
|
expect(serverInfo).not.toBeNull();
|
|
return serverInfo!;
|
|
}
|
|
|
|
describe("relay external socket reconnect behavior", () => {
|
|
beforeEach(() => {
|
|
sessionMock.instances.length = 0;
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("keeps the same session when relay reconnects within grace window", async () => {
|
|
const server = createServer();
|
|
const clientId = "cid-relay-reconnect";
|
|
|
|
const socket1 = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: socket1,
|
|
clientId,
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
const session = sessionMock.instances[0]!;
|
|
|
|
socket1.emit("close", 1006, "");
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(session.cleanup).not.toHaveBeenCalled();
|
|
|
|
const socket2 = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: socket2,
|
|
clientId,
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(session.cleanup).not.toHaveBeenCalled();
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("closes pending connection when hello timeout elapses", async () => {
|
|
const server = createServer();
|
|
|
|
const socket = new MockSocket();
|
|
let closeCode: number | null = null;
|
|
let closeReason = "";
|
|
socket.on("close", (code: unknown, reason: unknown) => {
|
|
closeCode = typeof code === "number" ? code : null;
|
|
closeReason = typeof reason === "string" ? reason : String(reason ?? "");
|
|
});
|
|
|
|
await (server as any).attachSocket(socket, createDirectRequest());
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
|
|
expect(closeCode).toBe(4001);
|
|
expect(closeReason).toBe("Hello timeout");
|
|
expect(sessionMock.instances).toHaveLength(0);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("returns server_info when clientId reconnects with existing session", async () => {
|
|
const server = createServer();
|
|
const clientId = "cid-resume-flag";
|
|
|
|
const firstSocket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: firstSocket,
|
|
clientId,
|
|
});
|
|
|
|
firstSocket.emit("close", 1006, "");
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
const secondSocket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: secondSocket,
|
|
clientId,
|
|
});
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("returns server_info for distinct clientIds", async () => {
|
|
const server = createServer();
|
|
|
|
const firstSocket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: firstSocket,
|
|
clientId: "cid-new-1",
|
|
});
|
|
|
|
const secondSocket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: secondSocket,
|
|
clientId: "cid-new-2",
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(2);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("rejects session messages before hello", async () => {
|
|
const server = createServer();
|
|
const socket = new MockSocket();
|
|
let closeCode: number | null = null;
|
|
let closeReason = "";
|
|
socket.on("close", (code: unknown, reason: unknown) => {
|
|
closeCode = typeof code === "number" ? code : null;
|
|
closeReason = typeof reason === "string" ? reason : String(reason ?? "");
|
|
});
|
|
|
|
await server.attachExternalSocket(socket, { transport: "relay" });
|
|
socket.emit(
|
|
"message",
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "ping",
|
|
},
|
|
}),
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(closeCode).toBe(4002);
|
|
expect(["Invalid hello", "Session message before hello"]).toContain(closeReason);
|
|
expect(sessionMock.instances).toHaveLength(0);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("reuses direct session when same clientId reconnects within grace window", async () => {
|
|
const server = createServer();
|
|
const clientId = "cid-direct-reconnect";
|
|
|
|
const socket1 = new MockSocket();
|
|
await attachDirectAndHello({
|
|
server,
|
|
socket: socket1,
|
|
clientId,
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
const session = sessionMock.instances[0]!;
|
|
|
|
socket1.emit("close", 1006, "");
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(session.cleanup).not.toHaveBeenCalled();
|
|
|
|
const socket2 = new MockSocket();
|
|
await attachDirectAndHello({
|
|
server,
|
|
socket: socket2,
|
|
clientId,
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(session.cleanup).not.toHaveBeenCalled();
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("reuses one session when switching from direct to relay with the same clientId", async () => {
|
|
const server = createServer();
|
|
const clientId = "cid-switch-path";
|
|
|
|
const directSocket = new MockSocket();
|
|
await attachDirectAndHello({
|
|
server,
|
|
socket: directSocket,
|
|
clientId,
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
const session = sessionMock.instances[0]!;
|
|
|
|
const relaySocket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: relaySocket,
|
|
clientId,
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
|
|
const onMessage = session.args.onMessage as
|
|
| ((msg: { type: "status"; payload: { status: string } }) => void)
|
|
| undefined;
|
|
expect(onMessage).toBeTypeOf("function");
|
|
onMessage?.({
|
|
type: "status",
|
|
payload: { status: "ok" },
|
|
});
|
|
|
|
expect(directSocket.sent.length).toBeGreaterThan(0);
|
|
expect(relaySocket.sent.length).toBeGreaterThan(0);
|
|
|
|
directSocket.emit("close", 1006, "");
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(session.cleanup).not.toHaveBeenCalled();
|
|
|
|
relaySocket.emit("close", 1006, "");
|
|
await vi.advanceTimersByTimeAsync(90_000);
|
|
expect(session.cleanup).toHaveBeenCalledTimes(1);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("cleans up relay session when reconnect grace expires", async () => {
|
|
const server = createServer();
|
|
const clientId = "cid-relay-grace-expire";
|
|
|
|
const socket1 = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket: socket1,
|
|
clientId,
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
const session = sessionMock.instances[0]!;
|
|
|
|
socket1.emit("close", 1006, "");
|
|
await vi.advanceTimersByTimeAsync(90_000);
|
|
expect(session.cleanup).toHaveBeenCalledTimes(1);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("includes voice capabilities in initial server_info when speech readiness exists", async () => {
|
|
const speechReadiness = createReadySpeechReadinessSnapshot();
|
|
const server = createServer({ speechReadiness });
|
|
|
|
const socket = new MockSocket();
|
|
const serverInfo = (await attachRelayAndHello({
|
|
server,
|
|
socket,
|
|
clientId: "cid-server-info-capabilities",
|
|
})) as {
|
|
version?: unknown;
|
|
capabilities?: {
|
|
voice?: {
|
|
dictation?: { enabled?: unknown; reason?: unknown };
|
|
voice?: { enabled?: unknown; reason?: unknown };
|
|
};
|
|
};
|
|
};
|
|
expect(serverInfo.version).toBe(TEST_DAEMON_VERSION);
|
|
expect(serverInfo.capabilities?.voice?.dictation?.enabled).toBe(
|
|
speechReadiness.dictation.enabled,
|
|
);
|
|
expect(serverInfo.capabilities?.voice?.dictation?.reason).toBe("");
|
|
expect(serverInfo.capabilities?.voice?.voice?.enabled).toBe(
|
|
speechReadiness.realtimeVoice.enabled,
|
|
);
|
|
expect(serverInfo.capabilities?.voice?.voice?.reason).toBe("");
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("broadcasts updated server_info when capabilities change", async () => {
|
|
const server = createServer();
|
|
|
|
const socket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket,
|
|
clientId: "cid-server-info-broadcast",
|
|
});
|
|
expect(socket.sent).toHaveLength(1);
|
|
|
|
const speechReadiness = createReadySpeechReadinessSnapshot();
|
|
server.publishSpeechReadiness(speechReadiness);
|
|
expect(socket.sent).toHaveLength(2);
|
|
|
|
const secondEnvelope = JSON.parse(socket.sent[1] as string) as {
|
|
message?: { payload?: unknown };
|
|
};
|
|
const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload);
|
|
expect(secondPayload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
|
expect(secondPayload?.capabilities?.voice?.voice.enabled).toBe(true);
|
|
|
|
// Same readiness should not produce another server_info broadcast.
|
|
server.publishSpeechReadiness(speechReadiness);
|
|
expect(socket.sent).toHaveLength(2);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("includes temporary retry guidance while models are downloading", async () => {
|
|
const server = createServer();
|
|
const socket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket,
|
|
clientId: "cid-server-info-download-guidance",
|
|
});
|
|
expect(socket.sent).toHaveLength(1);
|
|
|
|
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
|
|
expect(socket.sent).toHaveLength(2);
|
|
|
|
const envelope = JSON.parse(socket.sent[1] as string) as {
|
|
message?: { payload?: unknown };
|
|
};
|
|
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
|
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
|
expect(payload?.capabilities?.voice?.voice.enabled).toBe(true);
|
|
expect(payload?.capabilities?.voice?.dictation.reason).toContain("Try again in a few minutes.");
|
|
expect(payload?.capabilities?.voice?.voice.reason).toContain("Try again in a few minutes.");
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
|
|
const server = createServer();
|
|
|
|
const socket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket,
|
|
clientId: "cid-binary-inbound",
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
const session = sessionMock.instances[0]!;
|
|
|
|
socket.emit(
|
|
"message",
|
|
Buffer.from(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Input,
|
|
slot: 9,
|
|
payload: new TextEncoder().encode("ls\r"),
|
|
}),
|
|
),
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
|
|
const frame = session.handleBinaryFrame.mock.calls[0]?.[0] as {
|
|
opcode: number;
|
|
slot: number;
|
|
payload: Uint8Array;
|
|
};
|
|
expect(frame.opcode).toBe(TerminalStreamOpcode.Input);
|
|
expect(frame.slot).toBe(9);
|
|
expect(new TextDecoder().decode(frame.payload)).toBe("ls\r");
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("sends outbound terminal frames from session over websocket", async () => {
|
|
const server = createServer();
|
|
|
|
const socket = new MockSocket();
|
|
await attachRelayAndHello({
|
|
server,
|
|
socket,
|
|
clientId: "cid-binary-outbound",
|
|
});
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
const session = sessionMock.instances[0]!;
|
|
|
|
const onBinaryMessage = session.args.onBinaryMessage as
|
|
| ((frame: Uint8Array) => void)
|
|
| undefined;
|
|
expect(onBinaryMessage).toBeTypeOf("function");
|
|
|
|
onBinaryMessage?.(new Uint8Array([TerminalStreamOpcode.Output, 12, 0x6f, 0x6b]));
|
|
|
|
expect(socket.sent).toHaveLength(2);
|
|
const binaryPayload = asUint8Array(socket.sent[1]);
|
|
expect(binaryPayload).not.toBeNull();
|
|
const frame = decodeTerminalStreamFrame(binaryPayload!);
|
|
expect(frame).not.toBeNull();
|
|
expect(frame!.opcode).toBe(TerminalStreamOpcode.Output);
|
|
expect(frame!.slot).toBe(12);
|
|
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
|
|
|
|
await server.close();
|
|
});
|
|
});
|