import { describe, expect, it } from "vitest"; import { BridgeClient, type ClientDeps } from "../../extension/src/bridgeClient"; import { CLOSE, MAX_MESSAGE_BYTES } from "../../shared/protocol"; import { FakeSocket, FakeTimers } from "../helpers/fakeSocket"; const token = "ab12".repeat(8); const goodConfig = JSON.stringify({ url: "ws://127.0.0.1:4567/ext", token, profile: "test-qian" }); const flush = () => new Promise((r) => setTimeout(r, 0)); function setup(over: Partial = {}, configText: string | null | (() => string | null) = goodConfig) { const sockets: FakeSocket[] = []; const timers = new FakeTimers(); const badge: string[] = []; const handled: any[] = []; const welcomes: number[] = []; let loads = 0; const deps: ClientDeps = { loadConfigText: async () => (loads++, typeof configText === "function" ? configText() : configText), createSocket: (url) => { const s = new FakeSocket(url); sockets.push(s); return s; }, setTimer: timers.setTimer, clearTimer: timers.clearTimer, badge: { set: async (s) => void badge.push(s) }, handle: async (req) => (handled.push(req), { ok: true, echo: req.cmd }), extVersion: "0.1.0", chrome: "155.0.8059.39", onWelcome: (n) => void welcomes.push(n), ...over, }; const client = new BridgeClient(deps); const last = () => sockets[sockets.length - 1]!; /** Starts, opens and welcomes: the usual happy path. */ const connect = async (runId = "run-1") => { client.start(); await flush(); last().open(); last().receive({ type: "welcome", runId, protocol: 1 }); await flush(); }; return { client, sockets, timers, badge, handled, welcomes, last, connect, loads: () => loads }; } describe("BridgeClient: connecting", () => { it("reads the config and opens the socket to its url with the token", async () => { const t = setup(); t.client.start(); await flush(); expect(t.sockets).toHaveLength(1); expect(t.last().url).toBe(`ws://127.0.0.1:4567/ext?token=${token}`); }); it("says hello when the socket opens, without resume the first time", async () => { const t = setup(); t.client.start(); await flush(); t.last().open(); expect(t.last().messages).toEqual([{ type: "hello", extVersion: "0.1.0", chrome: "155.0.8059.39", profile: "test-qian" }]); }); it("becomes connected on welcome", async () => { const t = setup(); await t.connect(); expect(t.client.state).toBe("connected"); expect(t.badge.at(-1)).toBe("connected"); expect(t.client.runId).toBe("run-1"); }); it("does not open a second socket if start is called twice", async () => { const t = setup(); t.client.start(); t.client.start(); await flush(); expect(t.sockets).toHaveLength(1); }); it("shows disconnected while it is connecting", async () => { const t = setup(); t.client.start(); await flush(); expect(t.badge.at(-1)).toBe("disconnected"); }); }); describe("BridgeClient: configuration problems", () => { it("no bridge.json: disconnected, nothing opened, tries again later", async () => { const t = setup({}, null); t.client.start(); await flush(); expect(t.sockets).toHaveLength(0); expect(t.badge.at(-1)).toBe("disconnected"); expect(t.timers.delays).toEqual([500]); }); it("an invalid bridge.json: error badge, nothing opened, tries again later", async () => { const t = setup({}, JSON.stringify({ url: "ws://example.com:1/ext", token })); t.client.start(); await flush(); expect(t.sockets).toHaveLength(0); expect(t.badge.at(-1)).toBe("error"); expect(t.timers.count).toBe(1); }); it("a bridge.json that cannot be read: error badge, tries again later", async () => { const t = setup({ loadConfigText: async () => { throw new Error("fetch failed"); } }); t.client.start(); await flush(); expect(t.badge.at(-1)).toBe("error"); expect(t.timers.count).toBe(1); }); it("picks up a bridge.json that appears later", async () => { let text: string | null = null; const t = setup({}, () => text); t.client.start(); await flush(); text = goodConfig; t.timers.advance(500); await flush(); expect(t.sockets).toHaveLength(1); }); it("re-reads the config on every attempt, so a restarted bridge's new token is used", async () => { let text = goodConfig; const t = setup({}, () => text); await t.connect(); text = JSON.stringify({ url: "ws://127.0.0.1:9999/ext", token: "cd34".repeat(8) }); t.last().drop(); t.timers.advance(500); await flush(); expect(t.last().url).toBe(`ws://127.0.0.1:9999/ext?token=${"cd34".repeat(8)}`); }); }); describe("BridgeClient: reconnecting (P1-2.b)", () => { it("retries after a drop at 0.5 s, then 1, 2, 4, 8 and 8 s while it keeps failing", async () => { const t = setup(); await t.connect(); const seen: number[] = []; t.last().drop(); for (let i = 0; i < 6; i++) { seen.push(t.timers.delays[0]!); t.timers.advance(t.timers.delays[0]!); await flush(); t.last().drop(1006); // the bridge is still down } expect(seen).toEqual([500, 1000, 2000, 4000, 8000, 8000]); }); it("is back within 10 s however long the bridge was down once it is up again", async () => { const t = setup(); await t.connect(); t.last().drop(); for (let i = 0; i < 12; i++) { t.timers.advance(t.timers.delays[0]!); await flush(); t.last().drop(1006); } const before = t.sockets.length; t.timers.advance(8000); // the longest wait there can be await flush(); expect(t.sockets.length).toBe(before + 1); }); it("says hello again with the run id it was given (resume)", async () => { const t = setup(); await t.connect("run-7"); t.last().drop(); t.timers.advance(500); await flush(); t.last().open(); expect(t.last().messages[0]).toEqual({ type: "hello", extVersion: "0.1.0", chrome: "155.0.8059.39", profile: "test-qian", resume: "run-7" }); }); it("starts the backoff over after a successful welcome", async () => { const t = setup(); await t.connect(); t.last().drop(); t.timers.advance(500); await flush(); t.last().drop(1006); expect(t.timers.delays).toEqual([1000]); t.timers.advance(1000); await flush(); t.last().open(); t.last().receive({ type: "welcome", runId: "run-1", protocol: 1 }); await flush(); t.last().drop(); expect(t.timers.delays).toEqual([500]); }); it("shows disconnected when the connection drops", async () => { const t = setup(); await t.connect(); t.last().drop(); await flush(); expect(t.badge.at(-1)).toBe("disconnected"); expect(t.client.state).toBe("disconnected"); }); it("counts reconnects: welcomes after the first", async () => { const t = setup(); await t.connect(); expect(t.client.stats).toEqual({ welcomes: 1, reconnects: 0 }); t.last().drop(); t.timers.advance(500); await flush(); t.last().open(); t.last().receive({ type: "welcome", runId: "run-1", protocol: 1 }); await flush(); expect(t.client.stats).toEqual({ welcomes: 2, reconnects: 1 }); expect(t.welcomes).toEqual([1, 2]); }); it("ignores the close of a socket it has already replaced", async () => { const t = setup(); await t.connect(); const old = t.last(); old.drop(); t.timers.advance(500); await flush(); const fresh = t.last(); old.drop(1006); old.receive({ type: "ping" }); expect(t.timers.count).toBe(0); expect(fresh.messages.filter((m) => m.type === "pong")).toEqual([]); }); }); describe("BridgeClient: a failed attempt (an error with no close event, as Node's WebSocket does)", () => { it("counts an error before open as the end of the attempt and retries", async () => { const t = setup(); t.client.start(); await flush(); t.last().onerror?.(); expect(t.timers.delays).toEqual([500]); expect(t.client.state).toBe("disconnected"); expect(t.last().closedWith).toBeDefined(); // the dead socket is closed, not left open }); it("keeps retrying on the backoff schedule when every attempt only errors", async () => { const t = setup(); t.client.start(); await flush(); const seen: number[] = []; for (let i = 0; i < 6; i++) { t.last().onerror?.(); seen.push(t.timers.delays[0]!); t.timers.advance(t.timers.delays[0]!); await flush(); } expect(seen).toEqual([500, 1000, 2000, 4000, 8000, 8000]); }); it("an error followed by the close event schedules one retry, not two", async () => { const t = setup(); t.client.start(); await flush(); const s = t.last(); s.onerror?.(); s.drop(1006); expect(t.timers.count).toBe(1); }); it("an error on an open, welcomed connection drops it and retries", async () => { const t = setup(); await t.connect(); t.last().onerror?.(); expect(t.client.state).toBe("disconnected"); expect(t.timers.delays).toEqual([500]); }); it("an error from a socket that was already replaced is ignored", async () => { const t = setup(); await t.connect(); const old = t.last(); old.drop(); t.timers.advance(500); await flush(); old.onerror?.(); expect(t.timers.count).toBe(0); }); }); describe("BridgeClient: refusals (P1-2.a)", () => { it("a bad token (4401) shows the error badge, and it keeps trying so a new token is picked up", async () => { const t = setup(); t.client.start(); await flush(); t.last().open(); t.last().drop(CLOSE.badToken); await flush(); expect(t.badge.at(-1)).toBe("error"); expect(t.client.state).toBe("error"); expect(t.timers.count).toBe(1); }); it("a protocol version it does not know: error badge and the socket is closed", async () => { const t = setup(); t.client.start(); await flush(); t.last().open(); t.last().receive({ type: "welcome", runId: "r", protocol: 2 }); await flush(); expect(t.badge.at(-1)).toBe("error"); expect(t.last().closedWith?.code).toBe(CLOSE.protocolMismatch); expect(t.client.state).toBe("error"); }); it("after an error, a later successful welcome clears it", async () => { const t = setup(); t.client.start(); await flush(); t.last().open(); t.last().drop(CLOSE.badToken); t.timers.advance(500); await flush(); t.last().open(); t.last().receive({ type: "welcome", runId: "r", protocol: 1 }); await flush(); expect(t.badge.at(-1)).toBe("connected"); }); it("a replaced connection (4409) means another extension has taken over: error badge, and it does not fight back", async () => { const t = setup(); await t.connect(); t.last().drop(CLOSE.replaced); await flush(); expect(t.badge.at(-1)).toBe("error"); expect(t.client.state).toBe("error"); expect(t.timers.count).toBe(0); const sockets = t.sockets.length; t.client.onAlarm(); await flush(); expect(t.sockets.length).toBe(sockets); }); it("start() after a replacement connects again (a fresh worker is a fresh decision)", async () => { const t = setup(); await t.connect(); t.last().drop(CLOSE.replaced); await flush(); t.client.start(); await flush(); expect(t.sockets).toHaveLength(2); }); it("its own newer connection replacing its stale one is not a replacement: the stale socket's 4409 is ignored", async () => { const t = setup(); await t.connect(); const stale = t.last(); t.last().readyState = 3; // dead without a close event t.client.onAlarm(); await flush(); stale.drop(CLOSE.replaced); await flush(); expect(t.client.state).not.toBe("error"); }); }); describe("BridgeClient: messages", () => { it("answers a ping with a pong", async () => { const t = setup(); await t.connect(); t.last().receive({ type: "ping" }); expect(t.last().messages.at(-1)).toEqual({ type: "pong" }); }); it("runs a request through the handler and answers once with the same id", async () => { const t = setup(); await t.connect(); t.last().receive({ id: 7, cmd: "tabs" }); await flush(); expect(t.handled).toEqual([{ id: 7, cmd: "tabs" }]); expect(t.last().messages.filter((m) => m.id === 7)).toEqual([{ id: 7, ok: true, echo: "tabs" }]); }); it("answers an internal failure when the handler throws, and keeps running", async () => { const t = setup({ handle: async () => { throw new Error("boom"); } }); await t.connect(); t.last().receive({ id: 8, cmd: "act" }); await flush(); expect(t.last().messages.at(-1)).toMatchObject({ id: 8, ok: false, reason: "internal", detail: { message: "boom" } }); t.last().receive({ type: "ping" }); expect(t.last().messages.at(-1)).toEqual({ type: "pong" }); }); it("does not let the handler overwrite the id", async () => { const t = setup({ handle: async () => ({ id: 999, ok: true }) }); await t.connect(); t.last().receive({ id: 3, cmd: "tabs" }); await flush(); expect(t.last().messages.at(-1)).toMatchObject({ id: 3, ok: true }); }); it("ignores requests that arrive before the welcome", async () => { const t = setup(); t.client.start(); await flush(); t.last().open(); t.last().receive({ id: 1, cmd: "act" }); await flush(); expect(t.handled).toEqual([]); }); it.each([ ["malformed JSON", "{ nope"], ["a JSON array", "[1]"], ["an unknown type", { type: "teleport" }], ["a response nobody asked for", { id: 5, ok: true }], ["an event", { type: "event", kind: "x" }], ["a hello (the bridge never sends one)", { type: "hello", extVersion: "1", chrome: "1", profile: "p" }], ])("ignores %s", async (_n, message) => { const t = setup(); await t.connect(); const sentBefore = t.last().sent.length; t.last().receive(message); await flush(); expect(t.last().sent.length).toBe(sentBefore); expect(t.handled).toEqual([]); expect(t.client.state).toBe("connected"); }); it("ignores a message over 8 MB", async () => { const t = setup(); await t.connect(); t.last().receive("x".repeat(MAX_MESSAGE_BYTES + 1)); await flush(); expect(t.client.state).toBe("connected"); expect(t.handled).toEqual([]); }); it("does not crash when the socket closes while a response is being sent", async () => { let release: (v: unknown) => void = () => undefined; const t = setup({ handle: () => new Promise((r) => (release = r as never)) }); await t.connect(); t.last().receive({ id: 1, cmd: "tabs" }); await flush(); t.last().drop(); release({ ok: true }); await flush(); expect(t.client.state).toBe("disconnected"); }); it("does not send a response on a newer connection for a request from an older one", async () => { let release: (v: unknown) => void = () => undefined; const t = setup({ handle: () => new Promise((r) => (release = r as never)) }); await t.connect(); t.last().receive({ id: 1, cmd: "tabs" }); await flush(); t.last().drop(); t.timers.advance(500); await flush(); t.last().open(); t.last().receive({ type: "welcome", runId: "run-1", protocol: 1 }); await flush(); release({ ok: true }); await flush(); expect(t.last().messages.filter((m) => m.id === 1)).toEqual([]); }); }); describe("BridgeClient: the alarm backup (it wakes a worker that was stopped)", () => { it("connects if nothing is happening", async () => { const t = setup({}, null); t.client.start(); await flush(); // a worker restarts with no pending timer: the alarm is what brings it back t.timers.advance(0); t.client.onAlarm(); await flush(); expect(t.loads()).toBeGreaterThanOrEqual(2); }); it("does nothing while connected", async () => { const t = setup(); await t.connect(); const sockets = t.sockets.length; t.client.onAlarm(); await flush(); expect(t.sockets.length).toBe(sockets); }); it("replaces a socket that is dead but never reported it", async () => { const t = setup(); await t.connect(); t.last().readyState = 3; // closed without an onclose const sockets = t.sockets.length; t.client.onAlarm(); await flush(); expect(t.sockets.length).toBe(sockets + 1); }); it("does not start a second connection while one is being made", async () => { const t = setup(); t.client.start(); await flush(); const sockets = t.sockets.length; t.client.onAlarm(); await flush(); expect(t.sockets.length).toBe(sockets); }); it("does nothing after stop", async () => { const t = setup(); await t.connect(); t.client.stop(); t.client.onAlarm(); await flush(); expect(t.sockets).toHaveLength(1); }); }); describe("BridgeClient: stop", () => { it("closes the socket, cancels retries and stays down", async () => { const t = setup(); await t.connect(); t.client.stop(); expect(t.last().closedWith?.code).toBe(1000); t.last().drop(); expect(t.timers.count).toBe(0); expect(t.client.state).toBe("disconnected"); }); it("cancels a retry that was waiting", async () => { const t = setup({}, null); t.client.start(); await flush(); t.client.stop(); expect(t.timers.count).toBe(0); }); });