mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { ConnectionController, __testing__ } from "../connection-controller";
|
||||
import type { Transport } from "../../transport/transport";
|
||||
import type { ConnectionState } from "../../transport/types";
|
||||
import type { ConnectionStateHandler } from "../../transport/transport";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
|
||||
import type { HandshakeResult } from "../../transport/types";
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HELP_CANCEL,
|
||||
HELP_REQUEST,
|
||||
HELP_RESPONSE,
|
||||
isHelpCancelMessage,
|
||||
isHelpRequestMessage,
|
||||
} from "../help-bridge";
|
||||
|
||||
describe("help-bridge", () => {
|
||||
it("exposes stable message type constants", () => {
|
||||
expect(HELP_REQUEST).toBe("bsk-help-request");
|
||||
expect(HELP_RESPONSE).toBe("bsk-help-response");
|
||||
expect(HELP_CANCEL).toBe("bsk-help-cancel");
|
||||
});
|
||||
|
||||
it("type-guards a help-request message", () => {
|
||||
expect(
|
||||
isHelpRequestMessage({
|
||||
type: HELP_REQUEST,
|
||||
requestId: "r1",
|
||||
prompt: "log in",
|
||||
selectors: ["#login"],
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isHelpRequestMessage({ type: "borrow-request" })).toBe(false);
|
||||
expect(isHelpRequestMessage(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a help-request message with missing/wrong fields", () => {
|
||||
expect(isHelpRequestMessage({ type: HELP_REQUEST })).toBe(false);
|
||||
expect(
|
||||
isHelpRequestMessage({
|
||||
type: HELP_REQUEST,
|
||||
requestId: "r1",
|
||||
prompt: "p",
|
||||
selectors: "nope",
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHelpRequestMessage({
|
||||
type: HELP_REQUEST,
|
||||
requestId: "r1",
|
||||
prompt: "p",
|
||||
selectors: [123],
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("type-guards a help-cancel message", () => {
|
||||
expect(
|
||||
isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" }),
|
||||
).toBe(true);
|
||||
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isHelpCancelMessage(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getConnectionEnabled,
|
||||
getLabel,
|
||||
getOrCreateInstanceId,
|
||||
STORAGE_KEYS,
|
||||
setConnectionEnabled,
|
||||
setLabel,
|
||||
} from "../instance-id";
|
||||
|
||||
function fakeStorage(initial: Record<string, unknown> = {}) {
|
||||
const store = { ...initial };
|
||||
return {
|
||||
store,
|
||||
backend: {
|
||||
get: vi.fn(async (keys: string | string[]) => {
|
||||
const result: Record<string, unknown> = {};
|
||||
const list = Array.isArray(keys) ? keys : [keys];
|
||||
for (const k of list) if (k in store) result[k] = store[k];
|
||||
return result;
|
||||
}),
|
||||
set: vi.fn(async (items: Record<string, unknown>) => {
|
||||
Object.assign(store, items);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("instance-id", () => {
|
||||
it("generates a new 8-char hex id when storage is empty and persists it", async () => {
|
||||
const { store, backend } = fakeStorage();
|
||||
const id = await getOrCreateInstanceId(backend);
|
||||
expect(id).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
|
||||
expect(backend.set).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns the persisted short id on subsequent calls", async () => {
|
||||
const existing = "a7f32e1c";
|
||||
const { backend } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: existing });
|
||||
const id = await getOrCreateInstanceId(backend);
|
||||
expect(id).toBe(existing);
|
||||
expect(backend.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces legacy UUID storage with a short id", async () => {
|
||||
const legacy = "abcdef01-2345-4678-89ab-cdef01234567";
|
||||
const { backend, store } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: legacy });
|
||||
const id = await getOrCreateInstanceId(backend);
|
||||
expect(id).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect(id).not.toBe(legacy);
|
||||
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
|
||||
expect(backend.set).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("treats non-string stored values as missing", async () => {
|
||||
const { backend, store } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: 42 });
|
||||
const id = await getOrCreateInstanceId(backend);
|
||||
expect(id).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
|
||||
});
|
||||
|
||||
it("getLabel returns empty string when label is unset", async () => {
|
||||
const { backend } = fakeStorage();
|
||||
expect(await getLabel(backend)).toBe("");
|
||||
});
|
||||
|
||||
it("setLabel persists the value retrievable by getLabel", async () => {
|
||||
const { backend, store } = fakeStorage();
|
||||
await setLabel("Personal Chrome", backend);
|
||||
expect(store[STORAGE_KEYS.LABEL]).toBe("Personal Chrome");
|
||||
expect(await getLabel(backend)).toBe("Personal Chrome");
|
||||
});
|
||||
|
||||
it("getConnectionEnabled returns true when storage is empty", async () => {
|
||||
const { backend } = fakeStorage();
|
||||
expect(await getConnectionEnabled(backend)).toBe(true);
|
||||
});
|
||||
|
||||
it("getConnectionEnabled returns persisted boolean values", async () => {
|
||||
const { backend } = fakeStorage({ [STORAGE_KEYS.CONNECTION_ENABLED]: false });
|
||||
expect(await getConnectionEnabled(backend)).toBe(false);
|
||||
});
|
||||
|
||||
it("getConnectionEnabled treats non-boolean stored values as enabled", async () => {
|
||||
const { backend } = fakeStorage({ [STORAGE_KEYS.CONNECTION_ENABLED]: "false" });
|
||||
expect(await getConnectionEnabled(backend)).toBe(true);
|
||||
});
|
||||
|
||||
it("setConnectionEnabled persists the value retrievable by getConnectionEnabled", async () => {
|
||||
const { backend, store } = fakeStorage();
|
||||
await setConnectionEnabled(false, backend);
|
||||
expect(store[STORAGE_KEYS.CONNECTION_ENABLED]).toBe(false);
|
||||
expect(await getConnectionEnabled(backend)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import type { ConnectionState } from "@/transport/types";
|
||||
import { type AlarmsApi, KEEPALIVE_ALARM_NAME, startKeepalive } from "../keepalive";
|
||||
|
||||
function makeAlarms(): AlarmsApi & {
|
||||
triggers: Array<(alarm: { name: string }) => void>;
|
||||
created: Array<{ name: string; info: { periodInMinutes: number } }>;
|
||||
cleared: string[];
|
||||
} {
|
||||
const triggers: Array<(alarm: { name: string }) => void> = [];
|
||||
const created: Array<{ name: string; info: { periodInMinutes: number } }> = [];
|
||||
const cleared: string[] = [];
|
||||
return {
|
||||
triggers,
|
||||
created,
|
||||
cleared,
|
||||
create: (name, info) => {
|
||||
created.push({ name, info });
|
||||
},
|
||||
clear: (name) => {
|
||||
cleared.push(name);
|
||||
},
|
||||
onAlarm: {
|
||||
addListener: (cb) => triggers.push(cb),
|
||||
removeListener: (cb) => {
|
||||
const idx = triggers.indexOf(cb);
|
||||
if (idx >= 0) triggers.splice(idx, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTransport(initialState: ConnectionState = "disconnected"): Transport & {
|
||||
connectCalls: number;
|
||||
} {
|
||||
let state = initialState;
|
||||
const transport = {
|
||||
connectCalls: 0,
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
connect: vi.fn(async () => {
|
||||
state = "connected";
|
||||
transport.connectCalls += 1;
|
||||
}),
|
||||
disconnect: vi.fn(async () => {
|
||||
state = "disconnected";
|
||||
}),
|
||||
send: vi.fn(),
|
||||
onMessage: vi.fn(() => ({ dispose: () => {} })),
|
||||
onConnectionStateChange: vi.fn(() => ({ dispose: () => {} })),
|
||||
} as unknown as Transport & { connectCalls: number };
|
||||
return transport;
|
||||
}
|
||||
|
||||
describe("startKeepalive", () => {
|
||||
it("registers a 30s alarm and a listener on creation", () => {
|
||||
const alarms = makeAlarms();
|
||||
const transport = makeTransport();
|
||||
const handle = startKeepalive({ transport, alarms });
|
||||
|
||||
expect(alarms.created).toEqual([
|
||||
{ name: KEEPALIVE_ALARM_NAME, info: { periodInMinutes: 0.5 } },
|
||||
]);
|
||||
expect(alarms.triggers).toHaveLength(1);
|
||||
|
||||
handle.dispose();
|
||||
expect(alarms.cleared).toEqual([KEEPALIVE_ALARM_NAME]);
|
||||
expect(alarms.triggers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("kicks transport.connect() when the alarm fires and we are disconnected", async () => {
|
||||
const alarms = makeAlarms();
|
||||
const transport = makeTransport("disconnected");
|
||||
startKeepalive({ transport, alarms });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(transport.connectCalls).toBe(0);
|
||||
|
||||
// Fire the alarm; allow microtasks to drain.
|
||||
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(transport.connectCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not call transport.connect() while connected", async () => {
|
||||
const alarms = makeAlarms();
|
||||
const transport = makeTransport("connected");
|
||||
startKeepalive({ transport, alarms });
|
||||
|
||||
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(transport.connectCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores alarms for other names", async () => {
|
||||
const alarms = makeAlarms();
|
||||
const transport = makeTransport("disconnected");
|
||||
startKeepalive({ transport, alarms });
|
||||
alarms.triggers[0]({ name: "someone-else" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(transport.connectCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("swallows connect rejections so the alarm loop keeps running", async () => {
|
||||
const alarms = makeAlarms();
|
||||
const transport = makeTransport("disconnected");
|
||||
const mockedConnect = transport.connect as unknown as {
|
||||
mockImplementation: (fn: () => Promise<void>) => void;
|
||||
};
|
||||
mockedConnect.mockImplementation(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const handle = startKeepalive({ transport, alarms });
|
||||
await expect(handle.tickForTest()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reconnect when shouldConnect returns false", async () => {
|
||||
const alarms = makeAlarms();
|
||||
const transport = makeTransport("disconnected");
|
||||
startKeepalive({ transport, alarms, shouldConnect: () => false });
|
||||
|
||||
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(transport.connectCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("reconnects when shouldConnect returns true", async () => {
|
||||
const alarms = makeAlarms();
|
||||
const transport = makeTransport("disconnected");
|
||||
let enabled = false;
|
||||
startKeepalive({ transport, alarms, shouldConnect: () => enabled });
|
||||
|
||||
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(transport.connectCalls).toBe(0);
|
||||
|
||||
enabled = true;
|
||||
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(transport.connectCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
OVERLAY_AGENT_OVERLAY_RESET,
|
||||
OVERLAY_MSG_INTERRUPT,
|
||||
type OverlayInterruptRequest,
|
||||
type OverlayInterruptResponse,
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
} from "@/lib/overlay-bridge";
|
||||
|
||||
describe("OVERLAY_MSG_INTERRUPT", () => {
|
||||
it("constant matches the wire string content scripts will send", () => {
|
||||
expect(OVERLAY_MSG_INTERRUPT).toBe("overlay.interrupt");
|
||||
});
|
||||
|
||||
it("OverlayInterruptRequest type carries kind + sessionId", () => {
|
||||
const req: OverlayInterruptRequest = {
|
||||
kind: OVERLAY_MSG_INTERRUPT,
|
||||
sessionId: "sess-1",
|
||||
};
|
||||
expect(req.kind).toBe("overlay.interrupt");
|
||||
expect(req.sessionId).toBe("sess-1");
|
||||
});
|
||||
|
||||
it("OverlayInterruptResponse carries ok flag", () => {
|
||||
const res: OverlayInterruptResponse = { ok: true };
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOverlayAgentOverlayResetMessage", () => {
|
||||
it("accepts reset messages with a session id", () => {
|
||||
expect(
|
||||
isOverlayAgentOverlayResetMessage({
|
||||
type: OVERLAY_AGENT_OVERLAY_RESET,
|
||||
sessionId: "sess-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects reset messages without a session id", () => {
|
||||
expect(
|
||||
isOverlayAgentOverlayResetMessage({
|
||||
type: OVERLAY_AGENT_OVERLAY_RESET,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
|
||||
describe("sendInterrupt", () => {
|
||||
it("sends OVERLAY_MSG_INTERRUPT with the session id and resolves to the reply", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({ ok: true });
|
||||
const result = await sendInterrupt(sendMessage, "sess-9");
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
kind: "overlay.interrupt",
|
||||
sessionId: "sess-9",
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("treats an absent reply as failure", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await sendInterrupt(sendMessage, "sess-1");
|
||||
expect(result).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("treats a thrown sendMessage as failure", async () => {
|
||||
const sendMessage = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const result = await sendInterrupt(sendMessage, "sess-1");
|
||||
expect(result).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("resolves to ok=false if the round trip exceeds the timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sendMessage = vi.fn(() => new Promise(() => {})); // never resolves
|
||||
const promise = sendInterrupt(sendMessage, "sess-t", { timeoutMs: 2000 });
|
||||
vi.advanceTimersByTime(2000);
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ ok: false });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareProtocol, parseProtocolMajor } from "../semver";
|
||||
|
||||
describe("compareProtocol", () => {
|
||||
it("orders minor versions", () => {
|
||||
expect(compareProtocol("1.1", "1.0")).toBeGreaterThan(0);
|
||||
expect(compareProtocol("1.0", "1.1")).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("treats major-only as zero minor", () => {
|
||||
expect(compareProtocol("1", "1.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for unparseable input", () => {
|
||||
expect(compareProtocol("oops", "1.0")).toBeNull();
|
||||
expect(compareProtocol("1.0", "1.x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProtocolMajor (review round 3 I2: match Rust parse_major)", () => {
|
||||
// The daemon uses `value.split('.').next().and_then(|x|
|
||||
// x.parse::<u64>().ok())` (see `bsk-protocol::system::parse_major`).
|
||||
// The extension's parser MUST behave identically — any input the
|
||||
// daemon accepts has to round-trip to the same major here, and any
|
||||
// input the daemon rejects must return `null`. Pre-fix the regex
|
||||
// `/^(\d+)/` happily peeled off `1` from "1x" / "1e3" / " 1.0",
|
||||
// which would let `computeConnectedState` pass the protocol-major
|
||||
// gate on inputs that `evaluate_handshake_compat` would reject —
|
||||
// the same asymmetry round 3 I1 fought on the app-version axis.
|
||||
|
||||
it("rejects trailing non-digit characters (e.g. '1x')", () => {
|
||||
// Rust: 'split(.) -> "1x"' -> u64::parse fails -> None
|
||||
expect(parseProtocolMajor("1x")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects scientific notation (e.g. '1e3')", () => {
|
||||
// Rust u64 has no concept of "1e3"; ours must not silently
|
||||
// accept it either.
|
||||
expect(parseProtocolMajor("1e3")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty strings", () => {
|
||||
expect(parseProtocolMajor("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-numeric leading field (e.g. 'x.0')", () => {
|
||||
expect(parseProtocolMajor("x.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects negative numbers (e.g. '-1.0')", () => {
|
||||
// The daemon's strict digit parser rejects '-1' (the sign is not
|
||||
// an ASCII digit) before delegating to `u64::from_str`; /^\d+$/
|
||||
// rejects it here too.
|
||||
expect(parseProtocolMajor("-1.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects leading whitespace (e.g. ' 1.0')", () => {
|
||||
// The daemon's strict digit parser rejects leading whitespace
|
||||
// (space is not an ASCII digit); we must too.
|
||||
expect(parseProtocolMajor(" 1.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an explicit + sign (e.g. '+1.0')", () => {
|
||||
// `u64::from_str("+1")` actually returns `Ok(1)` — the std-lib
|
||||
// parser peels off a leading `+`. The daemon's strict digit
|
||||
// parser (see `parse_major` in `crates/bsk-protocol/src/system.rs`)
|
||||
// is what rejects this input, by requiring `/^\d+$/` on the head
|
||||
// segment before delegating to `u64::from_str`. We mirror that
|
||||
// reject here so the two parsers reach the same verdict.
|
||||
expect(parseProtocolMajor("+1.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects values past Number.MAX_SAFE_INTEGER conservatively", () => {
|
||||
// Round 5 I1: the daemon now caps `parse_major` at
|
||||
// `Number.MAX_SAFE_INTEGER` for parser symmetry, so both peers
|
||||
// reject the entire `MAX_SAFE_INTEGER < n <= u64::MAX` band
|
||||
// (previously the daemon accepted those values while the TS
|
||||
// side's `!Number.isSafeInteger` guard rejected them).
|
||||
const tooBig = "99999999999999999999"; // 20 digits, > 2^64 too
|
||||
expect(parseProtocolMajor(tooBig)).toBeNull();
|
||||
expect(parseProtocolMajor(`${tooBig}.0`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects MAX_SAFE_INTEGER + 1 ('9007199254740992')", () => {
|
||||
// First value above the cap. Matches the daemon's
|
||||
// `rejects_value_above_max_safe_integer` test on the Rust side.
|
||||
expect(parseProtocolMajor("9007199254740992")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects u64::MAX ('18446744073709551615')", () => {
|
||||
// A valid u64 literal but well above `Number.MAX_SAFE_INTEGER`.
|
||||
// Pre round 5 the daemon accepted this as `Some(u64::MAX)` while
|
||||
// the TS side rejected it via `!Number.isSafeInteger` — exactly
|
||||
// the asymmetry round 5 I1 closes.
|
||||
expect(parseProtocolMajor("18446744073709551615")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts leading zeros (e.g. '01.0' -> 1)", () => {
|
||||
// The daemon's `u64::from_str` accepts leading zeros after the
|
||||
// digit guard passes, so we must too.
|
||||
expect(parseProtocolMajor("01.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores everything past the first dot ('1.x' -> 1)", () => {
|
||||
// The daemon takes only `split('.').next()`, so the minor
|
||||
// segment's shape never affects the major.
|
||||
expect(parseProtocolMajor("1.x")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns the parsed major for the canonical wire format ('1.0.0' -> 1)", () => {
|
||||
expect(parseProtocolMajor("1.0.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns the parsed major for a single-segment input ('123' -> 123)", () => {
|
||||
expect(parseProtocolMajor("123")).toBe(123);
|
||||
});
|
||||
|
||||
it("accepts exactly Number.MAX_SAFE_INTEGER as the upper bound", () => {
|
||||
// Boundary case: at MAX_SAFE_INTEGER the value still round-trips
|
||||
// through `Number()` without precision loss, so we accept it.
|
||||
// The daemon's matching test is `accepts_value_at_max_safe_integer`.
|
||||
expect(parseProtocolMajor(`${Number.MAX_SAFE_INTEGER}`)).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user