import { describe, expect, it } from "vitest"; import { BACKOFF_MS, CLOSE, COMMANDS, FAILURE_REASONS, MAX_MESSAGE_BYTES, PING_INTERVAL_MS, PROTOCOL_VERSION, backoffDelay, isIdempotent, parseMessage, } from "../../shared/protocol"; describe("constants (from docs/design/protocol.md)", () => { it("protocol version 1, ping every 15 s, 8 MB limit", () => { expect(PROTOCOL_VERSION).toBe(1); expect(PING_INTERVAL_MS).toBe(15_000); expect(MAX_MESSAGE_BYTES).toBe(8 * 1024 * 1024); }); it("has the thirteen failure reasons (load-failed was added for a page the browser could not load)", () => { expect([...FAILURE_REASONS]).toEqual(["not-found", "ambiguous", "not-visible", "not-enabled", "timeout", "mismatch", "drifted", "navigation", "load-failed", "tab-closed", "connection-lost", "unsupported", "internal"]); }); it("close codes are distinct and in the private range", () => { const codes = Object.values(CLOSE); expect(new Set(codes).size).toBe(codes.length); for (const c of codes) expect(c).toBeGreaterThanOrEqual(4000); }); }); describe("backoffDelay", () => { it("is 0.5, 1, 2, 4, 8 s and then 8 s for ever", () => { expect([0, 1, 2, 3, 4, 5, 6, 50].map(backoffDelay)).toEqual([500, 1000, 2000, 4000, 8000, 8000, 8000, 8000]); expect([...BACKOFF_MS]).toEqual([500, 1000, 2000, 4000, 8000]); }); it("treats a negative or fractional attempt as the first", () => { expect(backoffDelay(-3)).toBe(500); expect(backoffDelay(1.9)).toBe(1000); }); }); describe("isIdempotent", () => { it("resolve, verify and tabs may be repeated; actions may not (and screenshot is not a bridge message at all)", () => { for (const c of ["resolve", "verify", "locate", "read", "wait", "outline", "tabs"]) expect(isIdempotent(c), c).toBe(true); for (const c of ["act", "navigate", "switchTab", "closeTab", "screenshot", "bogus"]) expect(isIdempotent(c), c).toBe(false); }); it("every known command is classified", () => { expect([...COMMANDS].sort()).toEqual(["act", "closeTab", "locate", "navigate", "outline", "read", "resolve", "switchTab", "tabs", "verify", "wait"]); }); }); describe("parseMessage", () => { const ok = (m: unknown) => parseMessage(JSON.stringify(m)); it("reads hello with and without resume", () => { expect(ok({ type: "hello", extVersion: "0.1.0", chrome: "155", profile: "p" })).toEqual({ ok: true, message: { type: "hello", extVersion: "0.1.0", chrome: "155", profile: "p" } }); const r = ok({ type: "hello", extVersion: "0.1.0", chrome: "155", profile: "p", resume: "run-1" }); expect(r).toMatchObject({ ok: true, message: { type: "hello", resume: "run-1" } }); }); it("rejects a hello with missing or mistyped fields", () => { expect(ok({ type: "hello", extVersion: "0.1.0" })).toEqual({ ok: false, reason: "invalid-hello" }); expect(ok({ type: "hello", extVersion: 1, chrome: "x", profile: "p" })).toEqual({ ok: false, reason: "invalid-hello" }); expect(ok({ type: "hello", extVersion: "1", chrome: "x", profile: "p", resume: 5 })).toEqual({ ok: false, reason: "invalid-hello" }); }); it("reads welcome, ping, pong", () => { expect(ok({ type: "welcome", runId: "r", protocol: 1 })).toMatchObject({ ok: true, message: { type: "welcome", runId: "r", protocol: 1 } }); expect(ok({ type: "ping" })).toEqual({ ok: true, message: { type: "ping" } }); expect(ok({ type: "pong" })).toEqual({ ok: true, message: { type: "pong" } }); expect(ok({ type: "welcome", runId: 3, protocol: 1 })).toEqual({ ok: false, reason: "invalid-welcome" }); }); it("reads requests, including ones with a command it does not know (the receiver answers 'unsupported')", () => { expect(ok({ id: 7, cmd: "tabs" })).toEqual({ ok: true, message: { id: 7, cmd: "tabs" } }); expect(ok({ id: 8, cmd: "teleport" })).toMatchObject({ ok: true, message: { id: 8, cmd: "teleport" } }); }); it("reads responses", () => { expect(ok({ id: 7, ok: true, tabs: [] })).toMatchObject({ ok: true, message: { id: 7, ok: true } }); expect(ok({ id: 7, ok: false, reason: "timeout" })).toMatchObject({ ok: true, message: { id: 7, ok: false, reason: "timeout" } }); }); it("reads events", () => { expect(ok({ type: "event", kind: "tab-closed", tab: 3 })).toMatchObject({ ok: true, message: { type: "event", kind: "tab-closed" } }); expect(ok({ type: "event" })).toEqual({ ok: false, reason: "invalid-event" }); }); it.each([ ["not JSON", "{ nope"], ["empty", ""], ["a JSON string", '"hi"'], ["a JSON array", "[1,2]"], ["null", "null"], ])("ignores %s", (_n, text) => { expect(parseMessage(text).ok).toBe(false); }); it("names the reason: malformed, not-an-object, unknown-shape", () => { expect(parseMessage("{ nope")).toEqual({ ok: false, reason: "malformed" }); expect(parseMessage("[1]")).toEqual({ ok: false, reason: "not-an-object" }); expect(parseMessage('{"hello":1}')).toEqual({ ok: false, reason: "unknown-shape" }); expect(parseMessage('{"type":"zzz"}')).toEqual({ ok: false, reason: "unknown-shape" }); expect(ok({ id: "7", cmd: "tabs" })).toEqual({ ok: false, reason: "unknown-shape" }); expect(ok({ id: 1.5, cmd: "tabs" })).toEqual({ ok: false, reason: "unknown-shape" }); }); it("rejects a message over the size limit without parsing it", () => { const big = "x".repeat(MAX_MESSAGE_BYTES + 1); expect(parseMessage(big)).toEqual({ ok: false, reason: "too-large" }); expect(parseMessage(Buffer.from(big))).toEqual({ ok: false, reason: "too-large" }); }); it("accepts a message exactly at the limit", () => { const pad = "y".repeat(MAX_MESSAGE_BYTES - JSON.stringify({ id: 1, ok: true, p: "" }).length); const text = JSON.stringify({ id: 1, ok: true, p: pad }); expect(Buffer.byteLength(text)).toBe(MAX_MESSAGE_BYTES); expect(parseMessage(text).ok).toBe(true); }); it("counts bytes, not characters", () => { const text = JSON.stringify({ id: 1, ok: true, p: "é".repeat(MAX_MESSAGE_BYTES / 2) }); expect(parseMessage(text)).toEqual({ ok: false, reason: "too-large" }); }); it("reads Buffer and ArrayBuffer input", () => { expect(parseMessage(Buffer.from('{"type":"ping"}')).ok).toBe(true); expect(parseMessage(new TextEncoder().encode('{"type":"pong"}').buffer as ArrayBuffer).ok).toBe(true); }); it("never throws, whatever the input", () => { for (const input of ["\u0000", "{\"type\":", "{\"__proto__\":{\"x\":1}}", "9".repeat(1000), "{\"id\":1e999,\"cmd\":\"x\"}"]) { expect(() => parseMessage(input)).not.toThrow(); } }); });