Skip to content
Merged
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
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bproxy/cli",
"version": "0.9.1",
"version": "0.9.2",
"private": true,
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bproxy/extension",
"version": "0.9.1",
"version": "0.9.2",
"private": true,
"type": "module",
"scripts": {
Expand Down
85 changes: 85 additions & 0 deletions extension/src/background/__tests__/status-query.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)["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,
});
}
});
});
17 changes: 17 additions & 0 deletions extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)["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
Expand Down
117 changes: 117 additions & 0 deletions extension/src/entrypoints/popup/__tests__/initialize.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
replaceChildren: ReturnType<typeof vi.fn>;
setAttribute: ReturnType<typeof vi.fn>;
append: ReturnType<typeof vi.fn>;
}

function fakeElement(): FakeElement {
return {
textContent: "",
dataset: {},
replaceChildren: vi.fn(),
setAttribute: vi.fn(),
append: vi.fn(),
};
}

function bootstrap(overrides: Partial<PairingBootstrap> = {}): 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<PairingBootstrap | null>("local:bootstrap", stored),
queryState: async () => state,
};
}

describe("initializePopup", () => {
let elements: Record<string, FakeElement>;

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("");
});
});
78 changes: 57 additions & 21 deletions extension/src/entrypoints/popup/__tests__/presentation.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -15,18 +14,6 @@ function expectLink(label: string, href: string): void {
);
}

function bootstrap(overrides: Partial<PairingBootstrap> = {}): 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("<title>bproxy</title>");
Expand Down Expand Up @@ -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",
});
});
Expand Down
Loading