mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
b3f3da1688
* fix(browser): surface extension action errors exactly once * fix(browser): reuse typed extension error response handler * fix(browser): settle tab sharing only after consent reconciliation * fix(browser): retain popup action errors across status refreshes * fix(browser): preserve popup failures across state transitions
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
type PopupMessage = {
|
|
type: string;
|
|
tabId?: number;
|
|
pairingString?: string;
|
|
};
|
|
|
|
type PopupState = {
|
|
paired?: boolean;
|
|
shared?: boolean;
|
|
failures: Partial<Record<"getStatus" | "pair" | "unpair" | "toggleShareTab", string>>;
|
|
onFailure?: (message: PopupMessage) => void;
|
|
};
|
|
|
|
async function loadPopup(params: PopupState) {
|
|
const markup = await fs.readFile(
|
|
path.join(process.cwd(), "extensions/browser/chrome-extension/popup.html"),
|
|
"utf8",
|
|
);
|
|
const parsed = new DOMParser().parseFromString(markup, "text/html");
|
|
document.head.innerHTML = parsed.head.innerHTML;
|
|
document.body.innerHTML = parsed.body.innerHTML;
|
|
|
|
const sendMessage = vi.fn(async (message: PopupMessage) => {
|
|
const failure = params.failures[message.type as keyof typeof params.failures];
|
|
if (failure) {
|
|
params.onFailure?.(message);
|
|
return { ok: false, error: failure };
|
|
}
|
|
switch (message.type) {
|
|
case "getStatus":
|
|
return {
|
|
paired: params.paired !== false,
|
|
state: "on",
|
|
sharedTabCount: params.shared ? 1 : 0,
|
|
relayUrl: "ws://127.0.0.1:18797/extension",
|
|
};
|
|
case "prepareCopilotPanel":
|
|
return { ok: true, path: "sidepanel.html?binding=fixture" };
|
|
case "isTabShared":
|
|
return { shared: params.shared === true };
|
|
default:
|
|
return { ok: true };
|
|
}
|
|
});
|
|
|
|
vi.stubGlobal("chrome", {
|
|
runtime: {
|
|
getManifest: vi.fn(() => ({ version: "2.1.0" })),
|
|
sendMessage,
|
|
},
|
|
tabs: { query: vi.fn(async () => [{ id: 44 }]) },
|
|
sidePanel: {
|
|
setOptions: vi.fn(async () => undefined),
|
|
open: vi.fn(async () => undefined),
|
|
},
|
|
});
|
|
|
|
const popupModulePath = "./popup.js";
|
|
await import(popupModulePath);
|
|
await vi.waitFor(() => {
|
|
if (params.paired === false) {
|
|
expect(sendMessage).toHaveBeenCalledWith({ type: "getStatus" });
|
|
return;
|
|
}
|
|
expect(sendMessage).toHaveBeenCalledWith({ type: "isTabShared", tabId: 44 });
|
|
});
|
|
|
|
return { sendMessage };
|
|
}
|
|
|
|
function popupElement(id: string): HTMLElement {
|
|
const element = document.getElementById(id);
|
|
if (!element) {
|
|
throw new Error(`Popup element ${id} is missing`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
async function expectVisibleErrorAfterStatusRefresh(
|
|
error: string,
|
|
sendMessage: Awaited<ReturnType<typeof loadPopup>>["sendMessage"],
|
|
) {
|
|
const initialPollCount = sendMessage.mock.calls.filter(
|
|
([message]) => message.type === "getStatus",
|
|
).length;
|
|
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
const refreshedPollCount = sendMessage.mock.calls.filter(
|
|
([message]) => message.type === "getStatus",
|
|
).length;
|
|
expect(refreshedPollCount).toBeGreaterThan(initialPollCount);
|
|
expect(popupElement("statusLine").textContent).toBe(error);
|
|
expect(popupElement("statusLine").closest(".hidden")).toBeNull();
|
|
}
|
|
|
|
describe("Chrome extension popup action errors", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
document.head.innerHTML = "";
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("keeps a rejected unpair visible while preserving the open settings panel", async () => {
|
|
const error = "Could not remove browser pairing.";
|
|
const popup = {
|
|
paired: true,
|
|
failures: { unpair: error } as Partial<Record<"unpair", string>>,
|
|
};
|
|
const { sendMessage } = await loadPopup(popup);
|
|
|
|
popupElement("settingsButton").click();
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
popupElement("unpairButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendMessage).toHaveBeenCalledWith({ type: "unpair" });
|
|
expect(popupElement("statusLine").textContent).toBe(error);
|
|
expect(popupElement("statusLine").closest(".hidden")).toBeNull();
|
|
expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
|
|
await expectVisibleErrorAfterStatusRefresh(error, sendMessage);
|
|
expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false);
|
|
|
|
delete popup.failures.unpair;
|
|
popup.paired = false;
|
|
popupElement("unpairButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("statusLine").textContent).toBe("Not paired with a gateway");
|
|
expect(popupElement("settingsSection").classList.contains("hidden")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("shows a rejected share-toggle error in the visible connected popup", async () => {
|
|
const error = "No tab with id: 44.";
|
|
const failures: Partial<Record<"getStatus" | "toggleShareTab", string>> = {
|
|
toggleShareTab: error,
|
|
};
|
|
const { sendMessage } = await loadPopup({ failures });
|
|
|
|
popupElement("shareButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendMessage).toHaveBeenCalledWith({ type: "toggleShareTab", tabId: 44 });
|
|
expect(popupElement("statusLine").textContent).toBe(error);
|
|
expect(popupElement("statusLine").closest(".hidden")).toBeNull();
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
|
|
await expectVisibleErrorAfterStatusRefresh(error, sendMessage);
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
|
|
failures.getStatus = "Could not refresh browser status.";
|
|
await expectVisibleErrorAfterStatusRefresh(error, sendMessage);
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
|
|
delete failures.getStatus;
|
|
await expectVisibleErrorAfterStatusRefresh(error, sendMessage);
|
|
|
|
delete failures.toggleShareTab;
|
|
popupElement("shareButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared");
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ action: "share", initiallyShared: false, nextLabel: "Stop sharing this tab" },
|
|
{ action: "unshare", initiallyShared: true, nextLabel: "Share this tab with OpenClaw" },
|
|
])(
|
|
"refreshes the actual tab controls immediately after a partially failed $action",
|
|
async ({ initiallyShared, nextLabel }) => {
|
|
const error = "Could not reconcile browser tab consent.";
|
|
const popup: PopupState = {
|
|
shared: initiallyShared,
|
|
failures: { toggleShareTab: error },
|
|
};
|
|
popup.onFailure = () => {
|
|
popup.shared = !popup.shared;
|
|
};
|
|
const { sendMessage } = await loadPopup(popup);
|
|
const previousPollCount = sendMessage.mock.calls.filter(
|
|
([message]) => message.type === "getStatus",
|
|
).length;
|
|
|
|
popupElement("shareButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("statusLine").textContent).toBe(error);
|
|
expect(popupElement("shareButton").textContent).toBe(nextLabel);
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
expect(
|
|
sendMessage.mock.calls.filter(([message]) => message.type === "getStatus").length,
|
|
).toBeGreaterThan(previousPollCount);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("clears a partial-unpair error after successfully pairing again", async () => {
|
|
const error = "Could not reconcile browser pairing.";
|
|
const popup: PopupState = {
|
|
paired: true,
|
|
failures: { unpair: error },
|
|
};
|
|
popup.onFailure = () => {
|
|
popup.paired = false;
|
|
};
|
|
const { sendMessage } = await loadPopup(popup);
|
|
|
|
popupElement("settingsButton").click();
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
popupElement("unpairButton").click();
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("statusLine").textContent).toBe(error);
|
|
expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false);
|
|
expect(popupElement("unpairButton").classList.contains("hidden")).toBe(true);
|
|
});
|
|
|
|
await expectVisibleErrorAfterStatusRefresh(error, sendMessage);
|
|
popupElement("settingsButton").click();
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("pairSection").classList.contains("hidden")).toBe(false);
|
|
expect(popupElement("statusLine").textContent).toBe(error);
|
|
});
|
|
|
|
popup.paired = true;
|
|
popupElement("pairButton").click();
|
|
await vi.waitFor(() => {
|
|
expect(sendMessage).toHaveBeenCalledWith({ type: "pair", pairingString: "" });
|
|
expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared");
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("keeps a partially persisted pairing failure visible after entering the connected view", async () => {
|
|
const error = "Could not finish browser pairing.";
|
|
const popup: PopupState = {
|
|
paired: false,
|
|
failures: { pair: error },
|
|
};
|
|
popup.onFailure = () => {
|
|
popup.paired = true;
|
|
};
|
|
const { sendMessage } = await loadPopup(popup);
|
|
const pairingInput = popupElement("pairingString") as HTMLTextAreaElement;
|
|
pairingInput.value = "ws://127.0.0.1:18797/extension#fixture-token";
|
|
|
|
popupElement("pairButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendMessage).toHaveBeenCalledWith({
|
|
type: "pair",
|
|
pairingString: pairingInput.value,
|
|
});
|
|
expect(popupElement("error").textContent).toBe(error);
|
|
expect(popupElement("pairSection").classList.contains("hidden")).toBe(true);
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
expect(popupElement("statusLine").textContent).toBe(error);
|
|
expect(popupElement("statusLine").closest(".hidden")).toBeNull();
|
|
});
|
|
|
|
await expectVisibleErrorAfterStatusRefresh(error, sendMessage);
|
|
popupElement("shareButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared");
|
|
expect(popupElement("connectedSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ view: "connected", section: "connectedSection", settings: false },
|
|
{ view: "settings", section: "settingsSection", settings: true },
|
|
])(
|
|
"keeps the existing $view view when a status poll fails and recovers",
|
|
async ({ section, settings }) => {
|
|
const error = "Could not read browser pairing.";
|
|
const failures: Partial<Record<"getStatus", string>> = {};
|
|
const { sendMessage } = await loadPopup({ failures });
|
|
if (settings) {
|
|
popupElement("settingsButton").click();
|
|
await vi.waitFor(() => {
|
|
expect(popupElement("settingsSection").classList.contains("hidden")).toBe(false);
|
|
});
|
|
}
|
|
const previousStatusClass = popupElement("statusDot").className;
|
|
|
|
failures.getStatus = error;
|
|
await expectVisibleErrorAfterStatusRefresh(error, sendMessage);
|
|
expect(popupElement(section).classList.contains("hidden")).toBe(false);
|
|
expect(popupElement("pairSection").classList.contains("hidden")).toBe(true);
|
|
expect(popupElement("statusDot").className).toBe(previousStatusClass);
|
|
|
|
delete failures.getStatus;
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
expect(popupElement("statusLine").textContent).toBe("Connected · 0 tabs shared");
|
|
expect(popupElement(section).classList.contains("hidden")).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("preserves the existing visible pairing failure", async () => {
|
|
const error = "Could not save browser pairing.";
|
|
const { sendMessage } = await loadPopup({ paired: false, failures: { pair: error } });
|
|
const pairingInput = popupElement("pairingString") as HTMLTextAreaElement;
|
|
pairingInput.value = "ws://127.0.0.1:18797/extension#fixture-token";
|
|
|
|
popupElement("pairButton").click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(sendMessage).toHaveBeenCalledWith({
|
|
type: "pair",
|
|
pairingString: pairingInput.value,
|
|
});
|
|
expect(popupElement("error").textContent).toBe(error);
|
|
expect(popupElement("error").closest(".hidden")).toBeNull();
|
|
});
|
|
});
|
|
});
|