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
19 changes: 19 additions & 0 deletions apps/extension/src/entrypoints/popup/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ describe("App", () => {
vi.unstubAllGlobals();
});

it("keeps an unpaired connection enabled and displays a nonblocking warning", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "connected",
localAuth: { state: "unverified_local", reason: "unpaired" },
},
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
expect(screen.getByText("已连接")).toBeTruthy();
expect(screen.getByText("未配对")).toBeTruthy();
expect(screen.getByRole("link", { name: "为什么建议配对?" })).toBeTruthy();
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(setConnectionEnabled).not.toHaveBeenCalled();
});

it("adds only a current-task section and keeps existing home controls accessible", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, state: "connected" },
Expand Down
43 changes: 41 additions & 2 deletions apps/extension/src/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,49 @@ export function App() {
>
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
<div className="flex min-w-0 items-center gap-2">
<ConnectionStatusIndicator state={statusState} />
<ConnectionStatusIndicator
state={statusState}
className={
statusState === "disabled"
? undefined
: ["pairing_required", "server_unverified", "daemon_too_old"].includes(
snapshot.localAuth?.state ?? "",
)
? "bg-destructive"
: undefined
}
/>
<span className="truncate text-sm font-medium" data-slot="popup-state-label">
{t(STATE_LABEL_KEYS[statusState])}
{t(
statusState !== "disabled" && snapshot.localAuth?.state === "pairing_required"
? "popup.localAuth.required"
: STATE_LABEL_KEYS[statusState],
)}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{connectionLive && snapshot.localAuth === null && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{t("popup.connectionRemote")}
</Badge>
)}
{connectionLive &&
snapshot.localAuth &&
["verified", "unverified_local"].includes(snapshot.localAuth.state) && (
<Badge
variant="outline"
className="px-1.5 py-0 text-[10px]"
data-slot="local-auth-badge"
>
{t(
snapshot.localAuth.state === "verified"
? "popup.localAuth.verified"
: snapshot.localAuth.reason === "unpaired"
? "popup.localAuth.unpaired"
: "popup.localAuth.unverified",
)}
</Badge>
)}
{isSkewed && (
<Badge
variant="outline"
Expand Down Expand Up @@ -255,6 +292,8 @@ export function App() {
<ConnectionSettings
connectionEnabled={snapshot.connectionEnabled}
disconnected={isDisconnected && !snapshot.lastError && !daemonUpgrade}
localAuth={snapshot.localAuth}
instanceId={snapshot.instanceId}
/>
</section>

Expand Down
176 changes: 174 additions & 2 deletions apps/extension/src/entrypoints/popup/connection-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { STORAGE_KEYS } from "@/lib/instance-id";
import type { RemoteEndpoint } from "@/transport/remote-endpoint";
import { readRemoteConnection } from "@/transport/remote-storage";
import { readRemoteConnection, readSavedRemoteConnection } from "@/transport/remote-storage";
import { ConnectionSettings } from "./connection-settings";

vi.mock("@/transport/remote-storage", () => ({
REMOTE_CONNECTION_REVISION: "revision",
REMOTE_CONNECTION_MODE: "mode",
readRemoteConnection: vi.fn(),
readSavedRemoteConnection: vi.fn(),
}));
vi.mock("./local-pairing", async (original) => ({
...(await original<typeof import("./local-pairing")>()),
readLocalPairingInfo: vi.fn(),
}));

import type { LocalAuthStatus } from "@/transport/local-auth";
import v from "../../../../../crates/bsk-protocol/tests/fixtures/local-auth-v1.json";
import { readLocalPairingInfo } from "./local-pairing";

const localLink = `ws://127.0.0.1:52800/#bsk-local.v1.${v.daemon_id}.${v.device_id}.${v.key}`;
const endpoint: RemoteEndpoint = {
url: "wss://browser.example/extension",
token: "a".repeat(43),
Expand All @@ -34,6 +45,15 @@ beforeEach(() => {
portWriteFails = false;
store = { [STORAGE_KEYS.DAEMON_PORT]: 52800 };
listeners = new Set();
vi.mocked(readLocalPairingInfo)
.mockReset()
.mockResolvedValue({
policy: { skip: false, everPaired: false, legacyCompat: false },
devices: [],
});
vi.mocked(readSavedRemoteConnection)
.mockReset()
.mockImplementation(async () => remote);
vi.mocked(readRemoteConnection)
.mockReset()
.mockImplementation(async () => {
Expand Down Expand Up @@ -205,7 +225,7 @@ it("keeps the remote grant when the proposed local port is invalid or cannot be
render(<ConnectionSettings connectionEnabled />);
fireEvent.click(screen.getByText("连接设置"));
await choose("本机");
expect(screen.getByText(/再次连接远程需要新的配对链接/)).toBeTruthy();
expect(screen.getByText(/远程配对会保留/)).toBeTruthy();
fireEvent.change(screen.getByLabelText("本机端口"), { target: { value: "65536" } });
submit();
expect(await screen.findByText("请输入 1 到 65535 之间的端口号。")).toBeTruthy();
Expand Down Expand Up @@ -278,3 +298,155 @@ it("does not declare an unconfirmed rotation invalid based on its old local expi
expect(await screen.findByText(/此前续期可能已成功/)).toBeTruthy();
expect(screen.queryByText("授权已过期,请使用新的配对链接重新连接。")).toBeNull();
});

it("pair: opens local pairing immediately and recognizes local links from either tab", async () => {
render(
<ConnectionSettings
connectionEnabled
instanceId="a1b2c3d4"
localAuth={{ state: "pairing_required" }}
/>,
);
await choose("远程");
fireEvent.change(screen.getByLabelText("配对链接"), { target: { value: localLink } });
expect(screen.getByRole("button", { name: "本机" }).getAttribute("aria-pressed")).toBe("true");
expect(document.querySelector("details")?.open).toBe(true);
vi.mocked(chrome.runtime.sendMessage).mockResolvedValueOnce({ ok: true });
submit();
await screen.findByText("配对成功。");
expect(chrome.runtime.sendMessage).toHaveBeenCalledExactlyOnceWith({
kind: "bsk-local-pairing",
action: "pair",
link: localLink,
});
expect(document.body.textContent).not.toContain(v.key);
});
function pairedInfo(skip = false) {
vi.mocked(readLocalPairingInfo).mockResolvedValue({
policy: { skip, everPaired: true, legacyCompat: false },
devices: [{ daemonId: v.daemon_id, deviceId: v.device_id, endpoint: "ws://127.0.0.1:52800/" }],
});
}
it("connected/settings: paired details and advanced settings stay collapsed until opened", async () => {
pairedInfo();
render(
<ConnectionSettings
connectionEnabled
localAuth={{ state: "verified", daemonId: v.daemon_id }}
/>,
);
await screen.findByText("使用新的配对链接");
expect(document.querySelector("details")?.open).toBe(false);
expect(document.querySelector<HTMLDetailsElement>('[data-slot="local-advanced"]')?.open).toBe(
false,
);
expect(screen.queryByLabelText("配对链接")).toBeNull();
fireEvent.click(screen.getByText("使用新的配对链接"));
expect(screen.getByLabelText("配对链接")).toBeTruthy();
});
it.each([
["already_paired", /bsk daemon pair --replace a1b2c3d4/],
["replace_mismatch", /bsk daemon revoke a1b2c3d4/],
] as const)("%s: replacement requires confirmation and a rejected link preserves the current grant", async (error, guidance) => {
pairedInfo();
render(
<ConnectionSettings
connectionEnabled
instanceId="b7e2f019"
localAuth={{ state: "verified", daemonId: v.daemon_id }}
/>,
);
fireEvent.click(await screen.findByText("使用新的配对链接"));
fireEvent.change(screen.getByLabelText("配对链接"), { target: { value: localLink } });
submit();
expect(screen.getByRole("alertdialog").textContent).toContain("替换成功后当前任务会结束");
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
vi.mocked(chrome.runtime.sendMessage).mockResolvedValueOnce({ error, browserId: "a1b2c3d4" });
fireEvent.click(screen.getByText("继续配对"));
expect(await screen.findByText(guidance)).toBeTruthy();
expect((screen.getByLabelText("配对链接") as HTMLInputElement).value).toBe(localLink);
expect(document.querySelector('[data-slot="local-paired-info"]')).toBeTruthy();
expect(chrome.runtime.sendMessage).toHaveBeenCalledTimes(1);
});
it("skip: requires confirmation and keeps restore guidance visible outside collapsed settings", async () => {
pairedInfo();
const { rerender } = render(<ConnectionSettings connectionEnabled />);
await screen.findByText("使用新的配对链接");
fireEvent.click(screen.getByRole("switch", { name: "跳过本机验证" }));
expect(screen.getByRole("alertdialog").textContent).toContain("bsk daemon trust-local on");
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
vi.mocked(chrome.runtime.sendMessage).mockResolvedValueOnce({ ok: true });
pairedInfo(true);
fireEvent.click(screen.getByText("确认跳过"));
await screen.findByText("恢复验证");
expect(chrome.runtime.sendMessage).toHaveBeenCalledExactlyOnceWith({
kind: "bsk-local-pairing",
action: "skip",
value: true,
});
rerender(
<ConnectionSettings
connectionEnabled
localAuth={{ state: "unverified_local", reason: "skip" }}
/>,
);
expect(
document.querySelector('[data-slot="local-pairing-notice"]')?.closest("details"),
).toBeNull();
pairedInfo(false);
vi.mocked(chrome.runtime.sendMessage).mockResolvedValueOnce({ ok: true });
fireEvent.click(screen.getByText("恢复验证"));
expect(await screen.findByText(/bsk daemon trust-local off/)).toBeTruthy();
});
it.each([
[{ state: "unverified_local", reason: "unpaired" }, /连接身份尚未验证/],
[{ state: "server_unverified" }, /服务未通过验证/],
[{ state: "daemon_too_old" }, /本机 bsk 版本过旧/],
] satisfies [
LocalAuthStatus,
RegExp,
][])("renders the %j state without revealing credentials", async (status, message) => {
render(<ConnectionSettings connectionEnabled localAuth={status} />);
await screen.findByText(message);
if (["server_unverified", "daemon_too_old"].includes(status.state)) {
vi.mocked(chrome.runtime.sendMessage).mockResolvedValueOnce({ ok: true });
fireEvent.click(screen.getByText("重试"));
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
kind: "bsk-local-pairing",
action: "retry",
});
}
expect(document.body.textContent).not.toContain(v.key);
});
it("unpaired: keeps settings collapsed and offers optional pairing and official security guidance", async () => {
render(
<ConnectionSettings
connectionEnabled
localAuth={{ state: "unverified_local", reason: "unpaired" }}
/>,
);
const help = await screen.findByRole("link", { name: "为什么建议配对?" });
expect(help.getAttribute("href")).toBe("https://browserskill.weixin.qq.com/#security");
expect(help.getAttribute("target")).toBe("_blank");
const settings = document.querySelector<HTMLDetailsElement>(
'[data-slot="popup-connection-settings"]',
);
expect(settings?.open).toBe(false);
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "去配对" }));
expect(settings?.open).toBe(true);
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
});
it("remote: resumes retained authorization before expiry without asking for a new link", async () => {
vi.mocked(readSavedRemoteConnection).mockResolvedValue(endpoint);
render(<ConnectionSettings connectionEnabled />);
await choose("远程");
expect(screen.getByText(/授权到期时间/)).toBeTruthy();
vi.mocked(chrome.runtime.sendMessage).mockResolvedValueOnce({ url: endpoint.url });
fireEvent.click(screen.getByText("切回远程"));
await screen.findByText("配对成功。");
expect(chrome.runtime.sendMessage).toHaveBeenCalledExactlyOnceWith({
kind: "bsk-remote-authorization",
resume: true,
});
});
Loading
Loading