diff --git a/cli/package.json b/cli/package.json index 9b3817d..558e8bc 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/cli", - "version": "0.9.1", + "version": "0.9.2", "private": true, "type": "module", "bin": { diff --git a/extension/package.json b/extension/package.json index 99692d6..54dd9c8 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/extension", - "version": "0.9.1", + "version": "0.9.2", "private": true, "type": "module", "scripts": { diff --git a/extension/src/background/__tests__/status-query.test.ts b/extension/src/background/__tests__/status-query.test.ts new file mode 100644 index 0000000..5515c01 --- /dev/null +++ b/extension/src/background/__tests__/status-query.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import type { BadgeState } from "../ws-client"; + +// The background entrypoint registers a chrome.runtime.onMessage listener +// for popup status queries. This test verifies the handler contract in +// isolation — the same message-matching logic and response shape used by +// the entrypoint, exercised without booting the full WXT defineBackground. + +type MessageHandler = ( + msg: unknown, + sender: unknown, + sendResponse: (response: unknown) => void, +) => boolean | undefined; + +/** + * Factory matching the entrypoint's inline handler. Takes a state getter + * (in production, `client.getState()`). + */ +function createStatusQueryHandler(getState: () => BadgeState): MessageHandler { + return (msg, _sender, sendResponse) => { + if ( + typeof msg === "object" && + msg !== null && + (msg as Record)["type"] === "status.query" + ) { + sendResponse({ type: "status.response", state: getState() }); + return true; + } + return undefined; + }; +} + +describe("status.query message handler", () => { + it("responds with current badge state for status.query messages", () => { + const handler = createStatusQueryHandler(() => "connected"); + const sendResponse = vi.fn(); + + const result = handler({ type: "status.query" }, {}, sendResponse); + + expect(result).toBe(true); + expect(sendResponse).toHaveBeenCalledWith({ + type: "status.response", + state: "connected", + }); + }); + + it("returns the live state at call time", () => { + let state: BadgeState = "disconnected"; + const handler = createStatusQueryHandler(() => state); + const sendResponse = vi.fn(); + + handler({ type: "status.query" }, {}, sendResponse); + expect(sendResponse).toHaveBeenCalledWith(expect.objectContaining({ state: "disconnected" })); + + sendResponse.mockClear(); + state = "connecting"; + handler({ type: "status.query" }, {}, sendResponse); + expect(sendResponse).toHaveBeenCalledWith(expect.objectContaining({ state: "connecting" })); + }); + + it("ignores non-status.query messages and does not call sendResponse", () => { + const handler = createStatusQueryHandler(() => "connected"); + const sendResponse = vi.fn(); + + expect(handler({ type: "pair.complete" }, {}, sendResponse)).toBeUndefined(); + expect(handler("not an object", {}, sendResponse)).toBeUndefined(); + expect(handler(null, {}, sendResponse)).toBeUndefined(); + expect(handler(42, {}, sendResponse)).toBeUndefined(); + expect(handler({ type: "other" }, {}, sendResponse)).toBeUndefined(); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it("handles all badge states", () => { + const states: BadgeState[] = ["connected", "disconnected", "connecting", "error"]; + for (const expected of states) { + const handler = createStatusQueryHandler(() => expected); + const sendResponse = vi.fn(); + handler({ type: "status.query" }, {}, sendResponse); + expect(sendResponse).toHaveBeenCalledWith({ + type: "status.response", + state: expected, + }); + } + }); +}); diff --git a/extension/src/entrypoints/background.ts b/extension/src/entrypoints/background.ts index aa2db32..b41f5cc 100644 --- a/extension/src/entrypoints/background.ts +++ b/extension/src/entrypoints/background.ts @@ -172,6 +172,23 @@ export default defineBackground(() => { }), ); dispatcher = makeDispatcher(client, tabs); + + // Popup status query handler. Returns the live WS connection state so + // the popup can display accurate pairing status without relying on the + // bootstrap envelope's expiresAt field (which is the code claim window, + // not the ongoing session validity). + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if ( + typeof msg === "object" && + msg !== null && + (msg as Record)["type"] === "status.query" + ) { + sendResponse({ type: "status.response", state: client.getState() }); + return true; + } + return undefined; + }); + client.start().catch(() => { // `start()` swallows expected branches (skip/reject) via badge state. // An unexpected throw lands here; surface it as the error badge so diff --git a/extension/src/entrypoints/popup/__tests__/initialize.test.ts b/extension/src/entrypoints/popup/__tests__/initialize.test.ts new file mode 100644 index 0000000..1217132 --- /dev/null +++ b/extension/src/entrypoints/popup/__tests__/initialize.test.ts @@ -0,0 +1,117 @@ +import { PROTOCOL_VERSION } from "@bproxy/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PairingBootstrap } from "../../../background/storage"; +import type { BadgeState } from "../../../background/ws-client"; +import { createFakeStorageItem } from "../../../test/fakes/storage"; +import { initializePopup, type PopupInitDeps } from "../main"; + +// Minimal DOM surface required by initializePopup's rendering helpers. +// The test environment is Node (not jsdom); we wire up just enough of +// the DOM API to exercise the async logic path without a full browser. + +interface FakeElement { + textContent: string; + dataset: Record; + replaceChildren: ReturnType; + setAttribute: ReturnType; + append: ReturnType; +} + +function fakeElement(): FakeElement { + return { + textContent: "", + dataset: {}, + replaceChildren: vi.fn(), + setAttribute: vi.fn(), + append: vi.fn(), + }; +} + +function bootstrap(overrides: Partial = {}): PairingBootstrap { + return { + extensionToken: "tok-123", + wsUrl: "ws://127.0.0.1:9615/ws", + protocolVersion: PROTOCOL_VERSION, + issuedAt: 100, + expiresAt: 10_000, + nonce: "nonce-1", + ...overrides, + }; +} + +function makeDeps( + state: BadgeState | null, + stored: PairingBootstrap | null = bootstrap(), +): PopupInitDeps { + return { + storage: createFakeStorageItem("local:bootstrap", stored), + queryState: async () => state, + }; +} + +describe("initializePopup", () => { + let elements: Record; + + beforeEach(() => { + elements = { + "version-info": fakeElement(), + "connection-status": fakeElement(), + submit: fakeElement(), + status: fakeElement(), + }; + const svgEl = fakeElement(); + vi.stubGlobal("document", { + getElementById: (id: string) => elements[id] ?? null, + createElementNS: () => svgEl, + createTextNode: (text: string) => ({ textContent: text }), + }); + }); + + it("renders paired state when background reports connected", async () => { + await initializePopup(makeDeps("connected")); + + expect(elements["connection-status"]!.replaceChildren).toHaveBeenCalled(); + expect(elements["submit"]!.textContent).toBe("Re-pair with new code"); + }); + + it("renders connecting state from live query", async () => { + await initializePopup(makeDeps("connecting")); + + expect(elements["submit"]!.textContent).toBe("Re-pair with new code"); + }); + + it("renders disconnected when has bootstrap but SW is disconnected", async () => { + await initializePopup(makeDeps("disconnected")); + + expect(elements["submit"]!.textContent).toBe("Re-pair with new code"); + }); + + it("renders not-paired when no bootstrap and query returns null", async () => { + await initializePopup(makeDeps(null, null)); + + expect(elements["submit"]!.textContent).toBe("Pair extension"); + }); + + it("handles storage read failure gracefully (treats as no bootstrap)", async () => { + const deps: PopupInitDeps = { + storage: { + async getValue() { + throw new Error("storage corrupt"); + }, + }, + queryState: async () => "disconnected", + }; + + await initializePopup(deps); + + // Storage failed → hasBootstrap=false, state=disconnected → "Not paired" + expect(elements["submit"]!.textContent).toBe("Pair extension"); + }); + + it("sets status output to idle after rendering", async () => { + await initializePopup(makeDeps("connected")); + + expect(elements["status"]!.dataset["state"]).toBe("idle"); + expect(elements["status"]!.textContent).toBe(""); + }); +}); diff --git a/extension/src/entrypoints/popup/__tests__/presentation.test.ts b/extension/src/entrypoints/popup/__tests__/presentation.test.ts index 17b7f3d..2795ff5 100644 --- a/extension/src/entrypoints/popup/__tests__/presentation.test.ts +++ b/extension/src/entrypoints/popup/__tests__/presentation.test.ts @@ -1,7 +1,6 @@ import { readFileSync } from "node:fs"; import { PROTOCOL_VERSION, VERSION } from "@bproxy/shared"; import { describe, expect, it } from "vitest"; -import type { PairingBootstrap } from "../../../background/storage"; import { formatVersionInfo, getConnectionStatusViewModel } from "../main"; const html = readFileSync(new URL("../index.html", import.meta.url), "utf8"); @@ -15,18 +14,6 @@ function expectLink(label: string, href: string): void { ); } -function bootstrap(overrides: Partial = {}): PairingBootstrap { - return { - extensionToken: "tok-123", - wsUrl: "ws://127.0.0.1:9615/ws", - protocolVersion: PROTOCOL_VERSION, - issuedAt: 100, - expiresAt: 10_000, - nonce: "nonce-1", - ...overrides, - }; -} - describe("popup presentation", () => { it("renders title, subtitle, status line, and stable pairing controls", () => { expect(html).toContain("bproxy"); @@ -64,26 +51,75 @@ describe("formatVersionInfo", () => { }); describe("getConnectionStatusViewModel", () => { - it("renders gray not-paired state when no bootstrap exists", () => { - expect(getConnectionStatusViewModel(null, 5_000)).toEqual({ + it("shows green paired state when live state is connected", () => { + expect(getConnectionStatusViewModel("connected", true)).toEqual({ + text: "Paired with local daemon", + tone: "ok", + submitLabel: "Re-pair with new code", + }); + }); + + it("shows green paired state even without bootstrap when connected", () => { + // Edge case: connected but storage read failed + expect(getConnectionStatusViewModel("connected", false)).toEqual({ + text: "Paired with local daemon", + tone: "ok", + submitLabel: "Re-pair with new code", + }); + }); + + it("shows connecting state when live state is connecting", () => { + expect(getConnectionStatusViewModel("connecting", true)).toEqual({ + text: "Connecting\u2026", + tone: "muted", + submitLabel: "Re-pair with new code", + }); + }); + + it("shows disconnected when has bootstrap but WS is down", () => { + expect(getConnectionStatusViewModel("disconnected", true)).toEqual({ + text: "Disconnected", + tone: "muted", + submitLabel: "Re-pair with new code", + }); + }); + + it("shows disconnected when has bootstrap but state query returned error", () => { + expect(getConnectionStatusViewModel("error", true)).toEqual({ + text: "Disconnected", + tone: "muted", + submitLabel: "Re-pair with new code", + }); + }); + + it("shows not-paired when no bootstrap and disconnected", () => { + expect(getConnectionStatusViewModel("disconnected", false)).toEqual({ text: "Not paired", tone: "muted", submitLabel: "Pair extension", }); }); - it("renders gray not-paired state when bootstrap is expired", () => { - expect(getConnectionStatusViewModel(bootstrap({ expiresAt: 4_000 }), 5_000)).toEqual({ + it("shows not-paired when no bootstrap and state is null (query failed)", () => { + expect(getConnectionStatusViewModel(null, false)).toEqual({ text: "Not paired", tone: "muted", submitLabel: "Pair extension", }); }); - it("renders green paired state when bootstrap is present and fresh", () => { - expect(getConnectionStatusViewModel(bootstrap(), 5_000)).toEqual({ - text: "Paired with local daemon", - tone: "ok", + it("shows not-paired when no bootstrap and state is error", () => { + expect(getConnectionStatusViewModel("error", false)).toEqual({ + text: "Not paired", + tone: "muted", + submitLabel: "Pair extension", + }); + }); + + it("shows disconnected when has bootstrap but state query returned null", () => { + expect(getConnectionStatusViewModel(null, true)).toEqual({ + text: "Disconnected", + tone: "muted", submitLabel: "Re-pair with new code", }); }); diff --git a/extension/src/entrypoints/popup/main.ts b/extension/src/entrypoints/popup/main.ts index 14dcde1..6699d63 100644 --- a/extension/src/entrypoints/popup/main.ts +++ b/extension/src/entrypoints/popup/main.ts @@ -1,5 +1,6 @@ import { PROTOCOL_VERSION, VERSION } from "@bproxy/shared"; import { bootstrapItem, type PairingBootstrap } from "../../background/storage"; +import type { BadgeState } from "../../background/ws-client"; import { type PairingErrorCode, type PairingResult, runPairing } from "./pairing"; // Thin DOM wiring for the popup. Real flow logic lives in small helpers: @@ -30,9 +31,9 @@ export interface ConnectionStatusViewModel { submitLabel: string; } -interface PopupInitDeps { +export interface PopupInitDeps { storage: { getValue(): Promise }; - now: () => number; + queryState: () => Promise; } function $(id: string): T { @@ -61,23 +62,41 @@ export function formatVersionInfo( return `${extensionPart} · ${protocolPart}`; } +/** + * Derive the popup's connection-status display from the background SW's live + * WebSocket state and the presence of a stored bootstrap token. + * + * Priority: + * 1. `connected` badge state → green "Paired with local daemon" + * 2. `connecting` badge state → muted "Connecting…" + * 3. Has stored token but disconnected → muted "Disconnected" + * 4. No token / error / unknown → muted "Not paired" + */ export function getConnectionStatusViewModel( - bootstrap: PairingBootstrap | null, - now: number, + liveState: BadgeState | null, + hasBootstrap: boolean, ): ConnectionStatusViewModel { - if ( - bootstrap && - typeof bootstrap.extensionToken === "string" && - bootstrap.extensionToken.length > 0 && - typeof bootstrap.expiresAt === "number" && - bootstrap.expiresAt > now - ) { + if (liveState === "connected") { return { text: "Paired with local daemon", tone: "ok", submitLabel: "Re-pair with new code", }; } + if (liveState === "connecting") { + return { + text: "Connecting\u2026", + tone: "muted", + submitLabel: "Re-pair with new code", + }; + } + if (hasBootstrap) { + return { + text: "Disconnected", + tone: "muted", + submitLabel: "Re-pair with new code", + }; + } return { text: "Not paired", tone: "muted", @@ -157,15 +176,48 @@ async function onSubmit(ev: SubmitEvent): Promise { } } +async function queryLiveState(): Promise { + try { + const response = await chrome.runtime.sendMessage({ type: "status.query" }); + if ( + typeof response === "object" && + response !== null && + (response as Record)["type"] === "status.response" + ) { + const state = (response as Record)["state"]; + if ( + state === "connected" || + state === "disconnected" || + state === "connecting" || + state === "error" + ) { + return state; + } + } + return null; + } catch { + // Background SW not reachable (e.g. during startup). Fall back to + // storage-only heuristic. + return null; + } +} + export async function initializePopup( deps: PopupInitDeps = { storage: bootstrapItem, - now: () => Date.now(), + queryState: queryLiveState, }, ): Promise { renderVersionInfo(formatVersionInfo()); - const bootstrap = await deps.storage.getValue().catch(() => null); - renderConnectionStatus(getConnectionStatusViewModel(bootstrap, deps.now())); + const [bootstrap, liveState] = await Promise.all([ + deps.storage.getValue().catch(() => null), + deps.queryState(), + ]); + const hasBootstrap = + bootstrap !== null && + typeof bootstrap.extensionToken === "string" && + bootstrap.extensionToken.length > 0; + renderConnectionStatus(getConnectionStatusViewModel(liveState, hasBootstrap)); setStatus("idle", ""); } diff --git a/package.json b/package.json index 5db23f6..ecd8437 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bproxy", "private": true, "type": "module", - "version": "0.9.1", + "version": "0.9.2", "description": "Human-in-the-loop browser proxy for AI agents.", "license": "MIT", "packageManager": "pnpm@9.15.0", diff --git a/service/package.json b/service/package.json index 6bc1fc1..050b356 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/service", - "version": "0.9.1", + "version": "0.9.2", "private": true, "type": "module", "bin": { diff --git a/shared/package.json b/shared/package.json index 8a68743..9ccbdaa 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/shared", - "version": "0.9.1", + "version": "0.9.2", "private": true, "type": "module", "exports": { diff --git a/shared/src/version.ts b/shared/src/version.ts index 4eaa82c..7da4792 100644 --- a/shared/src/version.ts +++ b/shared/src/version.ts @@ -6,7 +6,7 @@ */ /** Current bproxy package version (semver). */ -export const VERSION = "0.9.1"; +export const VERSION = "0.9.2"; /** Protocol version for the daemon↔CLI↔extension wire format. */ export const PROTOCOL_VERSION = 2; diff --git a/skills/bproxy/SKILL.md b/skills/bproxy/SKILL.md index 4b91477..7355518 100644 --- a/skills/bproxy/SKILL.md +++ b/skills/bproxy/SKILL.md @@ -9,7 +9,7 @@ description: >- compatibility: Node >=24, bproxy installed (npm install -g @dimdasci/bproxy), daemon running, extension paired license: MIT metadata: - version: "0.9.1" + version: "0.9.2" --- # bproxy diff --git a/views/package.json b/views/package.json index 55815f6..cf82cb7 100644 --- a/views/package.json +++ b/views/package.json @@ -1,7 +1,7 @@ { "name": "@bproxy/views", "private": true, - "version": "0.9.1", + "version": "0.9.2", "type": "module", "scripts": { "dev": "astro dev",