Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function event<T extends unknown[] = []>() {
};
}

async function fixture() {
async function fixture(diagnosticGet = vi.fn(async () => ({}))) {
const overlay = new OverlayController();
const onDetached = event<[number]>();
const sendMessage = vi.fn(
Expand All @@ -75,6 +75,9 @@ async function fixture() {
},
);
vi.stubGlobal("chrome", {
storage: {
local: { get: diagnosticGet, set: vi.fn(async () => {}), remove: vi.fn(async () => {}) },
},
tabs: {
sendMessage,
get: vi.fn(async () => ({ url: "https://example.test" })),
Expand Down Expand Up @@ -126,6 +129,14 @@ it.each([false, true])("hides an observed tab after detachment, remote=%s", asyn
expect(f.deps.cdp?.releaseSessionTab).toHaveBeenCalledWith("one", 20);
});

it("continues normal background startup while the diagnostic preference read is pending", async () => {
const diagnosticGet = vi.fn(() => new Promise<Record<string, unknown>>(() => {}));
const f = await fixture(diagnosticGet);
expect(diagnosticGet).toHaveBeenCalledOnce();
expect(f.task.sessionId).toBe("one");
expect(f.deps.sessions.get("one")).toBe(f.task);
});

it("still releases control and CDP when the content script cannot receive hidden", async () => {
const f = await fixture();
f.task.observedTabs = new Set([20]);
Expand Down
13 changes: 13 additions & 0 deletions apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { DebugManager } from "@/debug/manager";
import { getAuditEnabled } from "@/lib/audit";
import { attachAuditBridge } from "@/lib/audit-bridge";
import { ConnectionController } from "@/lib/connection-controller";
import {
handleDiagnosticMessage,
startConnectionDiagnostics,
} from "@/lib/connection-diagnostics-runtime";
import { watchDaemonConnection } from "@/lib/daemon-connection-preference";
import { startHeartbeat } from "@/lib/heartbeat";
import {
Expand Down Expand Up @@ -67,7 +71,16 @@ import { BrowserVideoHost } from "@/video/host";
import { VideoManager } from "@/video/manager";
import { VIDEO_OVERLAY } from "@/video/overlay";

declare const __BSK_EXT_BUILD__: string;

export default defineBackground(() => {
const diagnostics = startConnectionDiagnostics(chrome.storage.local, {
bootId: crypto.randomUUID(),
build: `${__BSK_EXT_VERSION__}@${typeof __BSK_EXT_BUILD__ === "string" ? __BSK_EXT_BUILD__ : "unknown"}`,
});
chrome.runtime.onMessage.addListener((message, sender, reply) =>
handleDiagnosticMessage(diagnostics, message, sender.id, chrome.runtime.id, reply),
);
const controller = new ConnectionController();
let remoteEndpoint: RemoteEndpoint | null = null;
let connectionPreferenceValid = false;
Expand Down
28 changes: 28 additions & 0 deletions apps/extension/src/entrypoints/connection-diagnostics/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BrowserSkill connection diagnostics</title>
<style>
body { max-width: 1100px; margin: 32px auto; padding: 0 20px; font: 16px/1.6 system-ui; color: #18202c; background: #f7f8fb; }
button { padding: 10px 16px; margin: 0 10px 12px 0; font: inherit; cursor: pointer; }
button:disabled { cursor: default; }
pre { padding: 20px; border: 1px solid #cbd1da; background: white; font: 13px/1.5 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<h1>Connection diagnostics</h1>
<p>Recording is off by default. Start before observing a connection problem, then close this page. After a failure, export the log before toggling the connection or opening worker DevTools.</p>
<p>Up to 2048 connection events are stored locally, without page content, URLs, tokens or RPC payloads. Nothing is uploaded automatically. Recording can affect worker scheduling; an empty log does not prove that the background was inactive.</p>
<button id="start" type="button" disabled>Start recording</button>
<button id="stop" type="button" disabled>Stop recording</button>
<button id="refresh" type="button" disabled>Read stored log</button>
<button id="download" type="button" disabled>Export JSON</button>
<button id="clear" type="button" disabled>Clear and stop</button>
<p>Reading and exporting only inspect stored data. Start, stop and clear are explicit controls that may wake the background worker. Stop preserves the log; clear removes diagnostic events.</p>
<p id="status" role="status" aria-live="polite">Reading stored state…</p>
<pre id="log" tabindex="0" aria-label="Stored connection events"></pre>
<script type="module" src="./main.ts"></script>
</body>
</html>
87 changes: 87 additions & 0 deletions apps/extension/src/entrypoints/connection-diagnostics/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { DIAGNOSTIC_STORAGE_KEY, type DiagnosticSnapshot } from "../../lib/connection-diagnostics";
import {
DIAGNOSTIC_CONTROL_KIND,
DIAGNOSTIC_ENABLED_KEY,
type DiagnosticCommand,
} from "../../lib/connection-diagnostics-runtime";

const status = document.getElementById("status")!;
const log = document.getElementById("log")!;
const button = (id: string) => document.getElementById(id) as HTMLButtonElement;
const buttons = ["start", "stop", "refresh", "download", "clear"].map(button);
let saved: DiagnosticSnapshot | null = null;
let firstReadAt: string | null = null;
function busy() {
for (const item of buttons) item.disabled = true;
}

async function read(): Promise<void> {
firstReadAt ??= new Date().toISOString();
busy();
try {
const stored = await chrome.storage.local.get([DIAGNOSTIC_ENABLED_KEY, DIAGNOSTIC_STORAGE_KEY]);
const enabled = stored[DIAGNOSTIC_ENABLED_KEY] === true;
const value = stored[DIAGNOSTIC_STORAGE_KEY] as DiagnosticSnapshot | undefined;
saved = value?.schema === 1 && Array.isArray(value.events) ? value : null;
log.textContent = saved ? JSON.stringify(saved, null, 2) : "";
status.textContent = `${enabled ? "Recording on" : "Recording off"}. ${
saved
? `${saved.events.length} events; ${saved.droppedEvents} dropped; ${saved.readFailures} read failures; ${saved.writeFailures} write failures. Snapshot: ${saved.capturedAt}.`
: "No valid stored log; this does not prove that the background was inactive."
} First read: ${firstReadAt}.`;
button("start").disabled = enabled;
button("stop").disabled = !enabled;
button("download").disabled = saved === null;
} catch {
saved = null;
log.textContent = "";
status.textContent =
"Unable to read stored diagnostics. Preserve the failure state and try reading again.";
button("stop").disabled = false;
} finally {
button("refresh").disabled = false;
button("clear").disabled = false;
}
}

async function control(command: DiagnosticCommand): Promise<void> {
busy();
let failed = false;
try {
const response = await chrome.runtime.sendMessage(command);
if (response?.ok !== true) failed = true;
} catch {
failed = true;
}
await read();
if (failed)
status.textContent = "Unable to change diagnostics. Read stored state before trying again.";
}

button("start").addEventListener("click", () => {
void control({ kind: DIAGNOSTIC_CONTROL_KIND, action: "set-enabled", enabled: true });
});
button("stop").addEventListener("click", () => {
void control({ kind: DIAGNOSTIC_CONTROL_KIND, action: "set-enabled", enabled: false });
});
button("clear").addEventListener("click", () => {
void control({ kind: DIAGNOSTIC_CONTROL_KIND, action: "clear" });
});
button("refresh").addEventListener("click", () => {
void read();
});
button("download").addEventListener("click", () => {
if (!saved) return;
const exportedAt = new Date().toISOString();
const blob = new Blob(
[JSON.stringify({ readerSchema: 1, firstReadAt, exportedAt, snapshot: saved }, null, 2)],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `bsk-connection-diagnostics-${exportedAt.replace(/[:.]/g, "-")}.json`;
anchor.click();
URL.revokeObjectURL(url);
});
void read();
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { WSTransport } from "../../transport/ws-transport";
import { ConnectionController } from "../connection-controller";
import { createDiagnosticRecorder, setDiagnosticRecorder } from "../connection-diagnostics";
import { KEEPALIVE_ALARM_NAME, startKeepalive } from "../keepalive";

vi.mock("../instance-id", () => ({
getOrCreateInstanceId: vi.fn(async () => "private-instance"),
getLabel: vi.fn(async () => "PRIVATE"),
}));
class Socket {
readyState = 0;
listeners = new Map<string, Array<(event: unknown) => void>>();
addEventListener(type: string, callback: (event: unknown) => void) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(callback);
this.listeners.set(type, listeners);
}
emit(type: string, event: unknown = {}) {
for (const callback of this.listeners.get(type) ?? []) callback(event);
}
open() {
this.readyState = 1;
this.emit("open");
}
close() {
this.readyState = 3;
this.emit("close", { code: 1000, wasClean: true, reason: "PRIVATE" });
}
serverClose() {
this.readyState = 3;
this.emit("close", { code: 1006, wasClean: false, reason: "PRIVATE" });
}
send(text: string) {
const frame = JSON.parse(text);
if (frame.method === "system.handshake")
queueMicrotask(() =>
this.emit("message", {
data: JSON.stringify({
id: frame.id,
result: {
server: "browser-skill-daemon",
version: "0.3.2",
protocol_version: "1.3",
min_compatible_protocol: "1.0",
},
}),
}),
);
}
}
async function drain() {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
function setup() {
const recorder = createDiagnosticRecorder(
{ read: async () => undefined, write: async () => {} },
{ bootId: "test-boot", build: "issue395.1" },
);
setDiagnosticRecorder(recorder);
const sockets: Socket[] = [];
const transport = new WSTransport({
url: "ws://127.0.0.1:52800/?token=PRIVATE",
webSocketFactory() {
const socket = new Socket();
sockets.push(socket);
return socket as unknown as WebSocket;
},
});
return { recorder, sockets, transport };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
setDiagnosticRecorder(null);
vi.useRealTimers();
});

it("records the actual keepalive callback and the controller gate for a pending physical connection", async () => {
const { recorder, transport } = setup();
const controller = new ConnectionController();
controller.requestConnect();
await controller.attach(transport, { name: "edge", version: "155" });
let alarmCallback!: (alarm: { name: string }) => void;
const keepalive = startKeepalive({
transport,
requestConnect: () => controller.requestConnect(),
alarms: {
create() {},
clear() {},
onAlarm: {
addListener(callback) {
alarmCallback = callback;
},
removeListener() {},
},
},
});
alarmCallback({ name: KEEPALIVE_ALARM_NAME });
await drain();
const events = recorder.snapshot().events;
expect(
events.some((e) => e.type === "controller.request" && e.details.gate === "not-ready"),
).toBe(true);
expect(
events.some(
(e) =>
e.type === "controller.request" &&
e.details.gate === "transport-state" &&
e.details.state === "connecting",
),
).toBe(true);
expect(events.some((e) => e.type === "alarm.tick")).toBe(true);
keepalive.dispose();
await controller.setConnectionEnabled(false);
});

it("distinguishes an unfinished cleanup from a pending socket without releasing the gate", async () => {
const { recorder, sockets, transport } = setup();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const controller = new ConnectionController();
await controller.attach(transport, { name: "edge", version: "155" }, true, {
onDisconnected: () => pending,
});
sockets[0].open();
await drain();
expect(controller.snapshot().state).toBe("connected");
sockets[0].serverClose();
await drain();
controller.requestConnect();
try {
const events = recorder.snapshot().events;
expect(events.some((e) => e.type === "cleanup.begin")).toBe(true);
expect(events.some((e) => e.type === "cleanup.complete")).toBe(false);
expect(
events.some((e) => e.type === "controller.request" && e.details.gate === "transition"),
).toBe(true);
} finally {
release();
await drain();
await controller.setConnectionEnabled(false);
}
});

it("records a native close code and generation without socket URL, close text or handshake data", async () => {
const { recorder, sockets, transport } = setup();
const pending = transport.connect();
sockets[0].open();
await pending;
sockets[0].serverClose();
expect(recorder.snapshot().events.find((e) => e.type === "socket.close")?.details).toMatchObject({
code: 1006,
generation: 1,
current: true,
});
expect(JSON.stringify(recorder.snapshot())).not.toContain("PRIVATE");
await transport.disconnect();
});

it("preserves transport behavior even when the observation sink throws", async () => {
const { sockets, transport } = setup();
setDiagnosticRecorder({
record() {
throw new Error("observer failed");
},
});
const pending = transport.connect();
sockets[0].open();
await expect(pending).resolves.toBeUndefined();
expect(transport.state).toBe("connected");
await expect(transport.disconnect()).resolves.toBeUndefined();
});
Loading