fix(browser): harden extension relay worker recovery

Co-authored-by: codexGW <9350182+codexGW@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-22 18:17:56 +01:00
parent 40494d67f2
commit 1fe2043742
6 changed files with 550 additions and 66 deletions

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
buildRelayWsUrl,
isRetryableReconnectError,
reconnectDelayMs,
} from "../../assets/chrome-extension/background-utils.js";
describe("chrome extension background utils", () => {
it("builds websocket url with encoded gateway token", () => {
const url = buildRelayWsUrl(18792, "abc/+= token");
expect(url).toBe("ws://127.0.0.1:18792/extension?token=abc%2F%2B%3D%20token");
});
it("throws when gateway token is missing", () => {
expect(() => buildRelayWsUrl(18792, "")).toThrow(/Missing gatewayToken/);
expect(() => buildRelayWsUrl(18792, " ")).toThrow(/Missing gatewayToken/);
});
it("uses exponential backoff from attempt index", () => {
expect(reconnectDelayMs(0, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
1000,
);
expect(reconnectDelayMs(1, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
2000,
);
expect(reconnectDelayMs(4, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
16000,
);
});
it("caps reconnect delay at max", () => {
const delay = reconnectDelayMs(20, {
baseMs: 1000,
maxMs: 30000,
jitterMs: 0,
random: () => 0,
});
expect(delay).toBe(30000);
});
it("adds jitter using injected random source", () => {
const delay = reconnectDelayMs(3, {
baseMs: 1000,
maxMs: 30000,
jitterMs: 1000,
random: () => 0.25,
});
expect(delay).toBe(8250);
});
it("sanitizes invalid attempts and options", () => {
expect(reconnectDelayMs(-2, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
1000,
);
expect(
reconnectDelayMs(Number.NaN, {
baseMs: Number.NaN,
maxMs: Number.NaN,
jitterMs: Number.NaN,
random: () => 0,
}),
).toBe(1000);
});
it("marks missing token errors as non-retryable", () => {
expect(
isRetryableReconnectError(
new Error("Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)"),
),
).toBe(false);
});
it("keeps transient network errors retryable", () => {
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
type ExtensionManifest = {
background?: { service_worker?: string; type?: string };
permissions?: string[];
};
function readManifest(): ExtensionManifest {
const path = resolve(process.cwd(), "assets/chrome-extension/manifest.json");
return JSON.parse(readFileSync(path, "utf8")) as ExtensionManifest;
}
describe("chrome extension manifest", () => {
it("keeps background worker configured as module", () => {
const manifest = readManifest();
expect(manifest.background?.service_worker).toBe("background.js");
expect(manifest.background?.type).toBe("module");
});
it("includes resilience permissions", () => {
const permissions = readManifest().permissions ?? [];
expect(permissions).toContain("alarms");
expect(permissions).toContain("webNavigation");
expect(permissions).toContain("storage");
expect(permissions).toContain("debugger");
});
});