Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
@@ -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);
});
});