Fixes all reachable typescript-eslint(no-unsafe-type-assertion) errors
in the four in-scope test files by:
- Adding Zod schemas (WireEnvelopeSchema, BinaryFrameSchema,
RuntimeMetricsLogSchema) and helpers (parseSentEnvelope, parseSentFrame,
assertStr) to parse wire frames instead of casting JSON.parse() results
- Replacing `JSON.parse(x) as { ... }` with Zod-validated helpers
- Replacing `mock.sent[N] as string` / `String(mock.sent[N])` with assertStr()
- Replacing `frame as Uint8Array` with direct type annotation (Buffer extends Uint8Array)
- Replacing `session.args.onMessage as (() => void) | undefined` with typeof
function guard and direct invocation
- Replacing `metricsCall![0] as RuntimeMetricsLog` with RuntimeMetricsLogSchema.parse()
Remaining `as unknown as SomeClass` patterns in constructor mocks and
`client as unknown as { ... }` internal-access patterns are out of scope:
they require extracting interfaces from concrete classes (AgentManager,
AgentStorage, FileBackedChatService, etc.) with private members, or
refactoring the client API to expose internal methods — neither is safe
to do in a test-only sweep.
789 lines
22 KiB
TypeScript
789 lines
22 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import type { Server as HTTPServer } from "http";
|
|
import type pino from "pino";
|
|
import type { AgentManager } from "./agent/agent-manager.js";
|
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
|
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
|
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
|
import type { FileBackedChatService } from "./chat/chat-service.js";
|
|
import type { LoopService } from "./loop-service.js";
|
|
import type { ScheduleService } from "./schedule/service.js";
|
|
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
|
import {
|
|
asUint8Array,
|
|
decodeTerminalStreamFrame,
|
|
encodeTerminalStreamFrame,
|
|
TerminalStreamOpcode,
|
|
} from "../shared/terminal-stream-protocol.js";
|
|
import { CLIENT_CAPS } from "../shared/client-capabilities.js";
|
|
|
|
type SocketListener = (...args: unknown[]) => void;
|
|
|
|
const wsModuleMock = vi.hoisted(() => {
|
|
class MockWebSocketServer {
|
|
static instances: MockWebSocketServer[] = [];
|
|
readonly handlers = new Map<string, (...args: unknown[]) => void>();
|
|
|
|
constructor(_options: unknown) {
|
|
MockWebSocketServer.instances.push(this);
|
|
}
|
|
|
|
on(event: string, handler: (...args: unknown[]) => 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) => {});
|
|
supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true);
|
|
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 { z } from "zod";
|
|
import { VoiceAssistantWebSocketServer } from "./websocket-server";
|
|
import { parseServerInfoStatusPayload } from "./messages.js";
|
|
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
|
|
|
interface WebSocketServerInternals {
|
|
attachSocket(ws: unknown, req: unknown): Promise<void>;
|
|
}
|
|
|
|
const TEST_DAEMON_VERSION = "1.2.3-test";
|
|
|
|
const WireEnvelopeSchema = z.object({
|
|
type: z.string().optional(),
|
|
message: z
|
|
.object({
|
|
type: z.string().optional(),
|
|
payload: z.unknown().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
function parseSentEnvelope(data: unknown): z.infer<typeof WireEnvelopeSchema> {
|
|
if (typeof data !== "string") throw new Error("Expected string frame");
|
|
return WireEnvelopeSchema.parse(JSON.parse(data));
|
|
}
|
|
|
|
const BinaryFrameSchema = z.object({
|
|
opcode: z.number(),
|
|
slot: z.number(),
|
|
payload: z.instanceof(Uint8Array),
|
|
});
|
|
|
|
class MockSocket {
|
|
readyState = 1;
|
|
bufferedAmount = 0;
|
|
sent: unknown[] = [];
|
|
private listeners = new Map<string, SocketListener[]>();
|
|
|
|
on(event: "message" | "close" | "error", listener: SocketListener): void {
|
|
const handlers = this.listeners.get(event) ?? [];
|
|
handlers.push(listener);
|
|
this.listeners.set(event, handlers);
|
|
}
|
|
|
|
once(event: "close" | "error", listener: SocketListener): void {
|
|
const wrapped: SocketListener = (...args) => {
|
|
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: unknown[]): void {
|
|
const handlers = this.listeners.get(event) ?? [];
|
|
for (const handler of handlers.slice()) {
|
|
handler(...args);
|
|
}
|
|
}
|
|
|
|
private off(event: "close" | "error", listener: SocketListener): 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;
|
|
const daemonConfigStore = {
|
|
onChange: vi.fn(() => () => {}),
|
|
};
|
|
return new VoiceAssistantWebSocketServer(
|
|
{} as unknown as HTTPServer,
|
|
createLogger() as unknown as pino.Logger,
|
|
"srv_test",
|
|
{
|
|
setAgentAttentionCallback: vi.fn(),
|
|
getAgent: vi.fn(() => null),
|
|
getMetricsSnapshot: vi.fn(() => ({
|
|
totalAgents: 0,
|
|
idleAgents: 0,
|
|
runningAgents: 0,
|
|
pendingPermissionAgents: 0,
|
|
erroredAgents: 0,
|
|
})),
|
|
} as unknown as AgentManager,
|
|
{} as unknown as AgentStorage,
|
|
{} as unknown as DownloadTokenStore,
|
|
"/tmp/paseo-test",
|
|
daemonConfigStore as unknown as DaemonConfigStore,
|
|
null,
|
|
{ allowedOrigins: new Set() },
|
|
undefined,
|
|
speechReadiness
|
|
? {
|
|
getReadiness: () => speechReadiness,
|
|
onReadinessChange: vi.fn(() => () => {}),
|
|
}
|
|
: undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
TEST_DAEMON_VERSION,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{} as unknown as FileBackedChatService,
|
|
{} as unknown as LoopService,
|
|
{} as unknown as ScheduleService,
|
|
{
|
|
subscribe: vi.fn(),
|
|
scheduleRefreshForCwd: vi.fn(),
|
|
getMetrics: vi.fn(() => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
})),
|
|
dispose: vi.fn(),
|
|
} as unknown as CheckoutDiffManager,
|
|
);
|
|
}
|
|
|
|
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,
|
|
options?: { capabilities?: Record<string, boolean> },
|
|
) {
|
|
return {
|
|
type: "hello" as const,
|
|
clientId,
|
|
clientType: "cli" as const,
|
|
protocolVersion: 1,
|
|
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
|
};
|
|
}
|
|
|
|
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 = parseSentEnvelope(params.socket.sent[0]);
|
|
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 unknown as WebSocketServerInternals).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 = parseSentEnvelope(params.socket.sent[0]);
|
|
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("passes hello capabilities through to the created session", async () => {
|
|
const server = createServer();
|
|
const socket = new MockSocket();
|
|
|
|
await (server as unknown as WebSocketServerInternals).attachSocket(
|
|
socket,
|
|
createDirectRequest(),
|
|
);
|
|
socket.emit(
|
|
"message",
|
|
JSON.stringify(
|
|
createHelloMessage("client-capabilities", {
|
|
capabilities: { [CLIENT_CAPS.reasoningMergeEnum]: true },
|
|
}),
|
|
),
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(sessionMock.instances).toHaveLength(1);
|
|
const session = sessionMock.instances[0];
|
|
expect(session.args.clientCapabilities).toEqual({
|
|
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
|
});
|
|
|
|
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 : "";
|
|
});
|
|
|
|
await (server as unknown as WebSocketServerInternals).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 : "";
|
|
});
|
|
|
|
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;
|
|
expect(onMessage).toBeTypeOf("function");
|
|
if (typeof onMessage === "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 = parseSentEnvelope(socket.sent[1]);
|
|
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 = parseSentEnvelope(socket.sent[1]);
|
|
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 = BinaryFrameSchema.parse(session.handleBinaryFrame.mock.calls[0]?.[0]);
|
|
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;
|
|
expect(onBinaryMessage).toBeTypeOf("function");
|
|
if (typeof onBinaryMessage === "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();
|
|
});
|
|
});
|