Files
BrowserSkill/apps/extension/src/lib/__tests__/connection-controller.test.ts
T

175 lines
6.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
import type { ConnectionStateHandler, Transport } from "../../transport/transport";
import type { ConnectionState, HandshakeResult } from "../../transport/types";
import { __testing__, ConnectionController } from "../connection-controller";
vi.mock("../instance-id", () => ({
getOrCreateInstanceId: vi.fn(async () => "a1b2c3d4"),
getLabel: vi.fn(async () => "test-label"),
}));
const { computeConnectedState } = __testing__;
function handshake(
daemonProtocol: string,
minCompatibleProtocol?: string,
daemonAppVersion = "0.1.0",
): HandshakeResult {
return {
server: "browser-skill-daemon",
version: daemonAppVersion,
protocol_version: daemonProtocol,
min_compatible_peer: "0.0.0",
min_compatible_protocol: minCompatibleProtocol,
};
}
describe("computeConnectedState (protocol-based compat)", () => {
it("returns connected when protocol strings match", () => {
expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
kind: "connected",
});
});
it("returns version_skew when daemon protocol minor is newer", () => {
expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({
kind: "version_skew",
});
});
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
expect(computeConnectedState(handshake("1", "1.0"))).toEqual({
kind: "version_skew",
});
});
it("rejects when protocol major differs", () => {
const result = computeConnectedState(handshake("2.0", "1.0"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("protocol-major mismatch");
}
});
it("rejects when extension is below daemon min_compatible_protocol", () => {
const result = computeConnectedState(handshake("1.0", "1.5"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("min_compatible_protocol");
expect(result.reason).toContain("1.5");
}
});
it("does not reject when min_compatible_protocol is missing (legacy daemon)", () => {
const result = computeConnectedState({
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_peer: "0.1.0",
});
expect(result).toEqual({ kind: "connected" });
});
it("rejects when daemon protocol is below extension floor", () => {
const result = computeConnectedState(handshake("1.0", "1.0"), "1.1");
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("below extension min_compatible_protocol");
}
});
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
const result = computeConnectedState(handshake("1.0", "not-a-protocol"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("daemon min_compatible_protocol");
expect(result.reason).toContain("not-a-protocol");
}
});
it("rejects unparseable daemon protocol", () => {
const result = computeConnectedState(handshake("oops", "1.0"));
expect(result.kind).toBe("rejected");
});
});
function makeMockTransport(initialState: ConnectionState = "disconnected") {
let state = initialState;
const stateHandlers = new Set<ConnectionStateHandler>();
const transport = {
get state() {
return state;
},
connect: vi.fn(async () => {
state = "connected";
for (const h of stateHandlers) h("connected");
}),
disconnect: vi.fn(async () => {
state = "disconnected";
for (const h of stateHandlers) h("disconnected");
}),
send: vi.fn(),
onMessage: vi.fn(() => ({ dispose: () => {} })),
onConnectionStateChange: vi.fn((handler: ConnectionStateHandler) => {
stateHandlers.add(handler);
return { dispose: () => stateHandlers.delete(handler) };
}),
emitState(next: ConnectionState) {
state = next;
for (const h of stateHandlers) h(next);
},
};
return transport as typeof transport & Transport;
}
describe("ConnectionController connectionEnabled", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not connect on attach when connection is disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
expect(transport.connect).not.toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(false);
expect(controller.snapshot().state).toBe("disconnected");
});
it("disconnects when connection is disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, true);
expect(transport.connect).toHaveBeenCalled();
await controller.setConnectionEnabled(false);
expect(transport.disconnect).toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(false);
expect(controller.snapshot().handshake).toBeNull();
expect(controller.snapshot().lastError).toBeNull();
});
it("reconnects when connection is re-enabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
await controller.setConnectionEnabled(true);
expect(transport.connect).toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(true);
});
it("ignores transport state changes while disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
transport.emitState("connected");
expect(controller.snapshot().state).toBe("disconnected");
});
});