diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index d5dee570..6f6a48f3 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -25,11 +25,28 @@ "js": [ "src/command_hub.tsx" ] + }, + { + "matches": [ + "https://selection-command.com/*", + "https://selection-command-hub.siro-cola.workers.dev/*", + "http://localhost:3000/*" + ], + "js": [ + "src/new_command_hub.tsx" + ] } ], "background": { "service_worker": "src/background_script.ts" }, + "externally_connectable": { + "matches": [ + "https://selection-command.com/*", + "https://selection-command-hub.siro-cola.workers.dev/*", + "http://localhost:3000/*" + ] + }, "options_page": "src/options_page.html", "action": {}, "permissions": [ diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index fc651649..6a61fc4c 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -28,7 +28,12 @@ import { execute } from "@/action/background" import * as ActionHelper from "@/action/helper" import type { WindowType } from "@/types" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" -import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { + ANALYTICS_EVENTS, + sendEvent, + getOrCreateClientId, +} from "@/services/analytics" +import * as HubBackground from "@/services/hub/background" import { importIf } from "@import-if" importIf("production", "./lib/sentry/initialize") @@ -211,7 +216,7 @@ const commandFuncs = { if (!cmd) { console.error("invalid command", param.command) - response(false) + response({ result: false, error: "Invalid command format" }) return true } @@ -227,8 +232,13 @@ const commandFuncs = { SCREEN.COMMAND_HUB, ) }) - .then(() => { - response(true) + .then(async () => { + const clientId = await getOrCreateClientId() + response({ result: true, install_id: clientId }) + }) + .catch((err) => { + console.error("[addCommand] Failed:", err) + response({ result: false, error: err?.message ?? "Unknown error" }) }) return true }, @@ -241,22 +251,20 @@ const commandFuncs = { const remove = async () => { const current = await Storage.getCommands() const commandToRemove = current.find((c) => c.id === param.id) - if (commandToRemove) { - const newCommands = current.filter((c) => c.id !== param.id) - if (newCommands.length === current.length) { - response(false) - return - } - await Storage.setCommands(newCommands) - await sendEvent( - ANALYTICS_EVENTS.COMMAND_REMOVE, - { - event_label: commandToRemove.openMode, - }, - SCREEN.COMMAND_HUB, - ) + if (!commandToRemove) { + response({ result: false, error: "Command not found" }) + return } - response(true) + const newCommands = current.filter((c) => c.id !== param.id) + await Storage.setCommands(newCommands) + await sendEvent( + ANALYTICS_EVENTS.COMMAND_REMOVE, + { + event_label: commandToRemove.openMode, + }, + SCREEN.COMMAND_HUB, + ) + response({ result: true }) } remove() return true @@ -401,6 +409,11 @@ const commandFuncs = { [BgCommand.getTabId]: getTabId, [BgCommand.getActiveTabId]: getActiveTabId, + // + // Hub + // + [BgCommand.shareCommandToHub]: HubBackground.shareCommandToHub, + // // PageAction // @@ -420,6 +433,8 @@ for (const key in BgCommand) { Ipc.addListener(command, commandFuncs[key]) } +HubBackground.initHubExternalListener() + const updateWindowSize = async ( commandId: string, width: number, diff --git a/packages/extension/src/components/commandHub/CommandHub.tsx b/packages/extension/src/components/commandHub/CommandHub.tsx index b730d975..986b1cbd 100644 --- a/packages/extension/src/components/commandHub/CommandHub.tsx +++ b/packages/extension/src/components/commandHub/CommandHub.tsx @@ -1,6 +1,3 @@ -import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" - export const CommandHub = (): JSX.Element => { - useCommandHubBridge() return <> } diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index 59d9e239..3df12e7b 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -51,6 +51,7 @@ import { PageActionHelp } from "@/components/help/PageActionHelp" import { CommandType } from "@/components/option/editor/CommandType" import { SearchUrlAssistButton } from "@/components/option/editor/SearchUrlAssistButton" import { SearchUrlAssistDialog } from "@/components/option/editor/SearchUrlAssistDialog" +import { hasCommandChanged } from "@/components/option/editor/commandChangedDetector" import { MenuImage } from "@/components/menu/MenuImage" import { Tooltip } from "@/components/Tooltip" import { PageActionStep } from "@/types/schema" @@ -93,7 +94,6 @@ import { commandSchema, CommandSchemaType, isPageActionType, - isAiPromptType, } from "@/types/schema" import type { SelectionCommand, @@ -468,13 +468,12 @@ const CommandEditDialogInner = ({ if (command.sourceType === COMMAND_SOURCE_TYPE.SELF_CREATED) return if (getValues("sourceType") === COMMAND_SOURCE_TYPE.SELF_UPDATED) return - const changed = - (isSearchType(command) && searchUrl !== command.searchUrl) || - (isPageActionType(command) && - JSON.stringify(pageActionOption) !== - JSON.stringify(command.pageActionOption)) || - (isAiPromptType(command) && - aiPromptPrompt !== command.aiPromptOption.prompt) + const changed = hasCommandChanged( + command, + searchUrl, + pageActionOption, + aiPromptPrompt, + ) if (changed) { setValue("sourceType", COMMAND_SOURCE_TYPE.SELF_UPDATED) diff --git a/packages/extension/src/components/option/editor/commandChangedDetector.test.ts b/packages/extension/src/components/option/editor/commandChangedDetector.test.ts new file mode 100644 index 00000000..66d11837 --- /dev/null +++ b/packages/extension/src/components/option/editor/commandChangedDetector.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest" +import { hasCommandChanged } from "./commandChangedDetector" +import { OPEN_MODE, PAGE_ACTION_OPEN_MODE } from "@/const" +import type { + SearchCommand, + PageActionCommand, + AiPromptCommand, + PageActionOption, +} from "@/types" + +const makeSearchCommand = ( + overrides: Partial = {}, +): SearchCommand => ({ + id: "cmd-1", + title: "Google", + iconUrl: "https://example.com/icon.png", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://example.com/search?q=%s", + ...overrides, +}) + +const makePageActionOption = ( + overrides: Partial = {}, +): PageActionOption => ({ + startUrl: "https://example.com", + openMode: PAGE_ACTION_OPEN_MODE.POPUP, + steps: [], + ...overrides, +}) + +const makePageActionCommand = ( + overrides: Partial> & { + pageActionOption?: Partial + } = {}, +): PageActionCommand => { + const { pageActionOption, ...rest } = overrides + return { + id: "cmd-2", + title: "Example Action", + iconUrl: "https://example.com/icon.png", + openMode: OPEN_MODE.PAGE_ACTION, + pageActionOption: makePageActionOption(pageActionOption), + ...rest, + } +} + +const makeAiPromptCommand = ( + overrides: Partial = {}, +): AiPromptCommand => ({ + id: "cmd-3", + title: "AI Summarize", + iconUrl: "https://example.com/icon.png", + openMode: OPEN_MODE.AI_PROMPT, + aiPromptOption: { + serviceId: "chatgpt", + prompt: "Summarize: {text}", + openMode: OPEN_MODE.POPUP, + }, + ...overrides, +}) + +describe("hasCommandChanged", () => { + describe("SearchCommand", () => { + it("returns false when searchUrl is unchanged", () => { + const cmd = makeSearchCommand() + expect(hasCommandChanged(cmd, cmd.searchUrl!, undefined, "")).toBe(false) + }) + + it("returns true when searchUrl is changed", () => { + const cmd = makeSearchCommand() + expect( + hasCommandChanged(cmd, "https://bing.com/search?q=%s", undefined, ""), + ).toBe(true) + }) + }) + + describe("PageActionCommand", () => { + it("returns false when pageActionOption is unchanged", () => { + const cmd = makePageActionCommand() + expect(hasCommandChanged(cmd, "", { ...cmd.pageActionOption }, "")).toBe( + false, + ) + }) + + it("returns true when startUrl is changed", () => { + const cmd = makePageActionCommand() + expect( + hasCommandChanged( + cmd, + "", + { ...cmd.pageActionOption, startUrl: "https://other.com" }, + "", + ), + ).toBe(true) + }) + + it("returns true when pageUrl is changed", () => { + const cmd = makePageActionCommand() + expect( + hasCommandChanged( + cmd, + "", + { ...cmd.pageActionOption, pageUrl: "https://example.com/*" }, + "", + ), + ).toBe(true) + }) + + it("returns false when only openMode is changed", () => { + const cmd = makePageActionCommand() + expect( + hasCommandChanged( + cmd, + "", + { ...cmd.pageActionOption, openMode: PAGE_ACTION_OPEN_MODE.TAB }, + "", + ), + ).toBe(false) + }) + + it("does not throw when currentPageActionOption is null, returns true (differs from saved)", () => { + const cmd = makePageActionCommand() + expect(hasCommandChanged(cmd, "", null, "")).toBe(true) + }) + + it("does not throw when currentPageActionOption is undefined, returns true (differs from saved)", () => { + const cmd = makePageActionCommand() + expect(hasCommandChanged(cmd, "", undefined, "")).toBe(true) + }) + }) + + describe("AiPromptCommand", () => { + it("returns false when prompt is unchanged", () => { + const cmd = makeAiPromptCommand() + expect( + hasCommandChanged(cmd, "", undefined, cmd.aiPromptOption.prompt), + ).toBe(false) + }) + + it("returns true when prompt is changed", () => { + const cmd = makeAiPromptCommand() + expect(hasCommandChanged(cmd, "", undefined, "Translate: {text}")).toBe( + true, + ) + }) + }) + + describe("other command types", () => { + it("returns false for CopyCommand", () => { + const cmd = makeSearchCommand({ openMode: OPEN_MODE.COPY }) + expect(hasCommandChanged(cmd, "", undefined, "")).toBe(false) + }) + }) +}) diff --git a/packages/extension/src/components/option/editor/commandChangedDetector.ts b/packages/extension/src/components/option/editor/commandChangedDetector.ts new file mode 100644 index 00000000..1f5354cc --- /dev/null +++ b/packages/extension/src/components/option/editor/commandChangedDetector.ts @@ -0,0 +1,27 @@ +import { isSearchType, isPageActionType, isAiPromptType } from "@/types/schema" +import type { SelectionCommand, PageActionOption } from "@/types" + +/** + * Returns true when the current form values differ from the saved command, + * ignoring pageActionOption.openMode (which represents display preference, + * not the command's core behavior). + */ +export function hasCommandChanged( + command: SelectionCommand, + currentSearchUrl: string, + currentPageActionOption: Partial | null | undefined, + currentAiPromptPrompt: string, +): boolean { + if (isSearchType(command)) { + return currentSearchUrl !== command.searchUrl + } + if (isPageActionType(command)) { + const { openMode: _a, ...pao } = currentPageActionOption ?? {} + const { openMode: _b, ...cmdPao } = command.pageActionOption + return JSON.stringify(pao) !== JSON.stringify(cmdPao) + } + if (isAiPromptType(command)) { + return currentAiPromptPrompt !== command.aiPromptOption.prompt + } + return false +} diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index a2807734..3f157077 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -323,7 +323,7 @@ export const HUB_URL = isDebug : "https://ujiro99.github.io/selection-command" export const NEW_HUB_URL = - import.meta.env?.VITE_NEW_HUB_URL ?? "https://selection-command-hub.pages.dev" + import.meta.env?.VITE_NEW_HUB_URL ?? "https://selection-command.com" export const NEW_HUB_SUPPORTED_LOCALES = [ "de", diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index fd06a080..e133623b 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -1,116 +1,13 @@ -import { useEffect, useRef } from "react" -import { Ipc, BgCommand } from "@/services/ipc" +import { useEffect } from "react" import { useSection } from "@/hooks/useSettings" import { CACHE_SECTIONS } from "@/services/settings/settingsCache" -import { - sendEvent, - ANALYTICS_EVENTS, - getOrCreateClientId, -} from "@/services/analytics" -import { SCREEN, NEW_HUB_URL } from "@/const" +import { NEW_HUB_URL } from "@/const" const hubOrigin = new URL(NEW_HUB_URL).origin -/** - * External postMessage API for adding/deleting commands from the Hub. - * - * This content script listens for messages from the Hub page (origin must match NEW_HUB_URL). - * The message object must have the following shape: - * - * --- AddCommand --- - * { - * action: "AddCommand", - * command: string // JSON-stringified command object (see below) - * } - * - * The `command` field is a JSON string representing a SearchCommand, an AiPromptCommand, or a PageActionCommand. - * - * SearchCommand (openMode is one of "popup" | "tab" | "window" | "backgroundTab" | "sidePanel"): - * { - * id: string, // Unique command identifier - * title: string, // Display name of the command - * searchUrl: string, // Search URL template (%s is replaced with selected text) - * iconUrl: string, // URL of the command icon - * openMode: string, // How to open the result: "popup" | "tab" | "window" | "backgroundTab" | "sidePanel" - * openModeSecondary?: string, // Secondary open mode (optional) - * spaceEncoding?: string, // Space encoding in URL: "plus" | "percent" (optional) - * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) - * sourceId?: string // Identifier of the source (optional) - * } - * - * AiPromptCommand (openMode is "aiPrompt"): - * { - * id: string, // Unique command identifier - * title: string, // Display name of the command - * iconUrl: string, // URL of the command icon - * openMode: "aiPrompt", // Must be "aiPrompt" for AI prompt commands - * aiPromptOption: { - * serviceId: string, // ID of the AI service to use (see hub/public/data/ai-services.json) - * prompt: string, // Prompt text sent to the AI service (supports variable placeholders) - * openMode: string // How to open the AI service result: "popup" | "tab" | "window" | etc. - * }, - * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) - * sourceId?: string // Identifier of the source (optional) - * } - * - * PageActionCommand (openMode is "pageAction"): - * { - * id: string, // Unique command identifier - * title: string, // Display name of the command - * iconUrl: string, // URL of the command icon - * openMode: "pageAction", // Must be "pageAction" for page action commands - * pageActionOption: { - * startUrl: string, // URL to open when executing the page action - * pageUrl?: string, // URL pattern for command enablement (currentTab mode only, optional) - * openMode: string, // How to open the page: "none" | "popup" | "tab" | "backgroundTab" | "window" | "currentTab" - * steps: Array, // Sequence of automation steps to execute - * userVariables?: Array<{ name: string, value: string }> // User-defined variables (optional) - * }, - * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) - * sourceId?: string // Identifier of the source (optional) - * } - * - * --- DeleteCommand --- - * { - * action: "DeleteCommand", - * id: string // ID of the command to remove - * } - * - * --- AddCommandAck (response) --- - * { - * action: "AddCommandAck", - * result: boolean, // true if the command was added successfully, false otherwise - * install_id: string // stable anonymous identifier per extension install (UUID, persisted in chrome.storage.local) - * } - * - * --- DeleteCommandAck (response) --- - * { - * action: "DeleteCommandAck", - * result: boolean // true if the command was removed successfully, false otherwise - * } - * - * --- RequestInstalledCommand (from Hub) --- - * { - * action: "RequestInstalledCommand" - * } - * - * --- SyncInstalledCommand (response / proactive push) --- - * { - * action: "SyncInstalledCommand", - * installedIds: string[] // IDs of all currently installed commands - * } - */ - export function useCommandHubBridge() { const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) - // Ref keeps the message handler (empty deps) in sync with the latest commands - // without needing to recreate the listener on every change. - const commandsRef = useRef(commands) - useEffect(() => { - commandsRef.current = commands - }, [commands]) - // Proactively push installed IDs to the Hub whenever the commands list changes. useEffect(() => { if (commands == null) return @@ -122,66 +19,4 @@ export function useCommandHubBridge() { hubOrigin, ) }, [commands]) - - useEffect(() => { - const handleMessage = async (event: MessageEvent) => { - if (event.origin !== hubOrigin) return - const { action, command, id } = event.data ?? {} - if (action === "AddCommand") { - if (typeof command !== "string") return - const install_id = await getOrCreateClientId() - Ipc.send(BgCommand.addCommand, { command }) - .then(async (res) => { - ;(event.source as WindowProxy)?.postMessage( - { action: "AddCommandAck", result: !!res, install_id }, - { targetOrigin: event.origin }, - ) - if (res) { - let commandId: string | undefined - try { - commandId = JSON.parse(command).id - } catch { - // Ignore parse errors; analytics will be sent without id - } - await sendEvent( - ANALYTICS_EVENTS.COMMAND_HUB_ADD, - { id: commandId }, - SCREEN.COMMAND_HUB, - ) - } - }) - .catch(() => { - ;(event.source as WindowProxy)?.postMessage( - { action: "AddCommandAck", result: false, install_id }, - { targetOrigin: event.origin }, - ) - }) - } else if (action === "DeleteCommand") { - if (typeof id !== "string") return - Ipc.send(BgCommand.removeCommand, { id }) - .then((res) => { - ;(event.source as WindowProxy)?.postMessage( - { action: "DeleteCommandAck", result: !!res }, - { targetOrigin: event.origin }, - ) - }) - .catch(() => { - ;(event.source as WindowProxy)?.postMessage( - { action: "DeleteCommandAck", result: false }, - { targetOrigin: event.origin }, - ) - }) - } else if (action === "RequestInstalledCommand") { - const ids = commandsRef.current?.map((c) => c.id) ?? [] - ;(event.source as WindowProxy)?.postMessage( - { action: "SyncInstalledCommand", installedIds: ids }, - { targetOrigin: event.origin }, - ) - } - } - window.addEventListener("message", handleMessage) - return () => { - window.removeEventListener("message", handleMessage) - } - }, []) } diff --git a/packages/extension/src/new_command_hub.tsx b/packages/extension/src/new_command_hub.tsx new file mode 100644 index 00000000..6bad11f7 --- /dev/null +++ b/packages/extension/src/new_command_hub.tsx @@ -0,0 +1,12 @@ +import { createRoot } from "react-dom/client" +import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" + +/** Minimal React component that activates the hub bridge hook. */ +function NewCommandHubBridge(): JSX.Element { + useCommandHubBridge() + return <> +} + +const rootDiv = document.createElement("div") +document.body.appendChild(rootDiv) +createRoot(rootDiv).render() diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts new file mode 100644 index 00000000..403c965e --- /dev/null +++ b/packages/extension/src/services/hub/background.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { shareCommandToHub, initHubExternalListener } from "./background" +import { Ipc, BgCommand } from "@/services/ipc" +import { Storage } from "@/services/storage" + +vi.mock("@/services/ipc", () => ({ + Ipc: { callListener: vi.fn() }, + BgCommand: { addCommand: "addCommand", removeCommand: "removeCommand" }, +})) + +vi.mock("@/services/storage", () => ({ + Storage: { getCommands: vi.fn() }, +})) + +vi.mock("@/const", () => ({ + NEW_HUB_URL: "https://hub.example.com", +})) + +const HUB_ORIGIN = "https://hub.example.com" + +type MessageListener = ( + message: Record, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, +) => boolean + +function getRegisteredListener(): MessageListener { + initHubExternalListener() + const calls = vi.mocked(chrome.runtime.onMessageExternal.addListener).mock + .calls + return calls[calls.length - 1][0] as MessageListener +} + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(global.chrome.runtime, "onConnectExternal", { + value: { addListener: vi.fn(), removeListener: vi.fn() }, + writable: true, + configurable: true, + }) +}) + +// --------------------------------------------------------------------------- +// initHubExternalListener +// --------------------------------------------------------------------------- + +describe("initHubExternalListener", () => { + it("INIT-01: registers a listener on onMessageExternal", () => { + initHubExternalListener() + expect(chrome.runtime.onMessageExternal.addListener).toHaveBeenCalledTimes( + 1, + ) + }) +}) + +// --------------------------------------------------------------------------- +// onMessageExternal — origin validation +// --------------------------------------------------------------------------- + +describe("onMessageExternal - origin validation", () => { + it("ORIGIN-01: processes message when origin matches hub", async () => { + vi.mocked(Storage.getCommands).mockResolvedValue([]) + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "RequestInstalledCommand" }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + expect(result).toBe(true) + }) + + it("ORIGIN-02: returns false when origin does not match hub", () => { + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "AddCommand", command: "{}" }, + { origin: "https://evil.example.com" }, + sendResponse, + ) + expect(result).toBe(false) + expect(sendResponse).not.toHaveBeenCalled() + expect(Ipc.callListener).not.toHaveBeenCalled() + }) + + it("ORIGIN-03: returns false when sender.origin is undefined", () => { + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "AddCommand", command: "{}" }, + {}, + sendResponse, + ) + expect(result).toBe(false) + expect(sendResponse).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// onMessageExternal — AddCommand +// --------------------------------------------------------------------------- + +describe("onMessageExternal - AddCommand", () => { + it("AC-01: calls Ipc.callListener and forwards result to sendResponse", async () => { + vi.mocked(Ipc.callListener).mockResolvedValue({ result: true } as any) + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "AddCommand", command: '{"id":"1"}' }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + expect(result).toBe(true) + expect(Ipc.callListener).toHaveBeenCalledWith(BgCommand.addCommand, { + command: '{"id":"1"}', + }) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ result: true }), + ) + }) + + it("AC-02: calls sendResponse with result:false when Ipc.callListener rejects", async () => { + vi.mocked(Ipc.callListener).mockRejectedValue(new Error("IPC error")) + const listener = getRegisteredListener() + const sendResponse = vi.fn() + listener( + { action: "AddCommand", command: '{"id":"1"}' }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ + result: false, + error: "IPC error", + }), + ) + }) + + it("AC-03: does not handle AddCommand when command is not a string", () => { + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "AddCommand", command: 42 }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + expect(result).toBe(false) + expect(Ipc.callListener).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// onMessageExternal — DeleteCommand +// --------------------------------------------------------------------------- + +describe("onMessageExternal - DeleteCommand", () => { + it("DC-01: calls Ipc.callListener and forwards result to sendResponse", async () => { + vi.mocked(Ipc.callListener).mockResolvedValue({ result: true } as any) + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "DeleteCommand", id: "cmd-123" }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + expect(result).toBe(true) + expect(Ipc.callListener).toHaveBeenCalledWith(BgCommand.removeCommand, { + id: "cmd-123", + }) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ result: true }), + ) + }) + + it("DC-02: calls sendResponse with result:false when Ipc.callListener rejects", async () => { + vi.mocked(Ipc.callListener).mockRejectedValue(new Error("IPC error")) + const listener = getRegisteredListener() + const sendResponse = vi.fn() + listener( + { action: "DeleteCommand", id: "cmd-123" }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ + result: false, + error: "IPC error", + }), + ) + }) + + it("DC-03: does not handle DeleteCommand when id is not a string", () => { + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "DeleteCommand", id: 999 }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + expect(result).toBe(false) + expect(Ipc.callListener).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// onMessageExternal — RequestInstalledCommand +// --------------------------------------------------------------------------- + +describe("onMessageExternal - RequestInstalledCommand", () => { + it("RI-01: returns installed command IDs via sendResponse", async () => { + vi.mocked(Storage.getCommands).mockResolvedValue([ + { id: "a" }, + { id: "b" }, + ] as any) + const listener = getRegisteredListener() + const sendResponse = vi.fn() + const result = listener( + { action: "RequestInstalledCommand" }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + expect(result).toBe(true) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ + action: "SyncInstalledCommand", + installedIds: ["a", "b"], + }), + ) + }) + + it("RI-02: returns empty installedIds when getCommands rejects", async () => { + vi.mocked(Storage.getCommands).mockRejectedValue(new Error("storage error")) + const listener = getRegisteredListener() + const sendResponse = vi.fn() + listener( + { action: "RequestInstalledCommand" }, + { origin: HUB_ORIGIN }, + sendResponse, + ) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ + action: "SyncInstalledCommand", + installedIds: [], + }), + ) + }) +}) + +// --------------------------------------------------------------------------- +// shareCommandToHub +// --------------------------------------------------------------------------- + +describe("shareCommandToHub", () => { + const param = { locale: "en", id: "cmd-1" } as any + const sender = {} as any + + it("SH-07: returns true immediately (async response marker)", () => { + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 1 } as chrome.tabs.Tab) + return Promise.resolve({ id: 1 } as chrome.tabs.Tab) + }) + const response = vi.fn() + const result = shareCommandToHub(param, sender, response) + expect(result).toBe(true) + }) + + it("SH-01: opens hub tab and calls response(true)", async () => { + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + await vi.waitFor(() => expect(response).toHaveBeenCalledWith(true)) + expect(chrome.runtime.onConnectExternal.addListener).toHaveBeenCalledTimes( + 1, + ) + }) + + it("SH-02: calls response(false) when tab.id is undefined", async () => { + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({} as chrome.tabs.Tab) + return Promise.resolve({} as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + await vi.waitFor(() => expect(response).toHaveBeenCalledWith(false)) + }) + + it("SH-03: calls response(false) and removes listener when chrome.tabs.create throws", async () => { + vi.mocked(chrome.tabs.create).mockImplementation(() => { + throw new Error("tabs.create error") + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + await vi.waitFor(() => expect(response).toHaveBeenCalledWith(false)) + expect(chrome.runtime.onConnectExternal.removeListener).toHaveBeenCalled() + }) + + it("SH-04: ignores port with wrong name", async () => { + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + + // Simulate port connect with wrong name + const portConnectListener = vi.mocked( + chrome.runtime.onConnectExternal.addListener, + ).mock.calls[0][0] + const mockPort = { + name: "wrong-name", + sender: { tab: { id: 42 } }, + postMessage: vi.fn(), + onMessage: { addListener: vi.fn(), removeListener: vi.fn() }, + } + portConnectListener(mockPort as any) + + await vi.waitFor(() => expect(response).toHaveBeenCalledWith(true)) + expect(mockPort.postMessage).not.toHaveBeenCalled() + }) + + it("SH-05: ignores port from a different tab", async () => { + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + + // Let the async share() function resume past the await so `tabId` is initialized + await Promise.resolve() + + const portConnectListener = vi.mocked( + chrome.runtime.onConnectExternal.addListener, + ).mock.calls[0][0] + const mockPort = { + name: "hub-share", + sender: { tab: { id: 99 } }, // different tab ID + postMessage: vi.fn(), + onMessage: { addListener: vi.fn(), removeListener: vi.fn() }, + } + portConnectListener(mockPort as any) + + await vi.waitFor(() => expect(response).toHaveBeenCalledWith(true)) + expect(mockPort.postMessage).not.toHaveBeenCalled() + }) + + it("SH-06: valid port connect sends share-command and removes listener on ack", async () => { + vi.useFakeTimers() + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + + await Promise.resolve() + + const portConnectListener = vi.mocked( + chrome.runtime.onConnectExternal.addListener, + ).mock.calls[0][0] + + let capturedOnMessage: ((msg: unknown) => void) | undefined + const mockPort = { + name: "hub-share", + sender: { tab: { id: 42 } }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn((fn) => { + capturedOnMessage = fn + }), + removeListener: vi.fn(), + }, + } + portConnectListener(mockPort as any) + + // Advance timer to trigger first postMessage + vi.advanceTimersByTime(500) + + expect(mockPort.postMessage).toHaveBeenCalledWith({ + type: "share-command", + command: param, + }) + expect(chrome.runtime.onConnectExternal.removeListener).toHaveBeenCalled() + + // Simulate ack + capturedOnMessage?.({ type: "share-command-ack" }) + expect(mockPort.onMessage.removeListener).toHaveBeenCalled() + + vi.useRealTimers() + }) +}) diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts new file mode 100644 index 00000000..65c25372 --- /dev/null +++ b/packages/extension/src/services/hub/background.ts @@ -0,0 +1,147 @@ +import { NEW_HUB_URL } from "@/const" +import { Ipc, BgCommand } from "@/services/ipc" +import type { Sender } from "@/services/ipc" +import { Storage } from "@/services/storage" +import type { SubmitCommandInput } from "@/services/hubShare" + +const RETRY_INTERVAL_MS = 100 +const MAX_RETRIES = 20 // 2 seconds + +const hubOrigin = new URL(NEW_HUB_URL).origin + +export const shareCommandToHub = ( + param: SubmitCommandInput, + _: Sender, + response: (res: unknown) => void, +): boolean => { + let retries = 0 + // tabId is set after the tab is created; onPortConnect checks against this value + // so that early port connections (before the await resolves) are safely ignored. + let tabId: number | undefined + // Declared outside try so it can be removed in the catch block as well. + let onPortConnect: ((port: chrome.runtime.Port) => void) | undefined + + const share = async () => { + try { + // Use a named function expression so the handler can remove itself via + // `portConnect` (inner self-reference, always valid inside the handler). + // The outer `onPortConnect` variable is used for cleanup in error paths + // (tab creation failure / catch block) where the inner name is not in scope. + onPortConnect = function portConnect(port: chrome.runtime.Port) { + if (port.name !== "hub-share") return + if (port.sender?.tab?.id !== tabId) return + + chrome.runtime.onConnectExternal.removeListener(portConnect) + + const cleanup = () => { + clearInterval(timer) + port.onMessage.removeListener(onMessage) + } + + const onMessage = (msg: unknown) => { + if ((msg as { type?: string })?.type === "share-command-ack") { + cleanup() + } + } + port.onMessage.addListener(onMessage) + + // Post the command repeatedly until ack is received or max retries exceeded + const timer = setInterval(() => { + retries++ + if (retries > MAX_RETRIES) { + cleanup() + console.error( + "[Hub] Hub page did not respond to share-command in time.", + ) + return + } + port.postMessage({ type: "share-command", command: param }) + }, RETRY_INTERVAL_MS) + } + // Register listener before tab creation so the Hub page can connect immediately on load + chrome.runtime.onConnectExternal.addListener(onPortConnect) + + const hubUrl = `${NEW_HUB_URL}/${param.locale}/dashboard/commands` + const tab = await new Promise((resolve) => + chrome.tabs.create({ url: hubUrl }, resolve), + ) + if (!tab?.id) { + chrome.runtime.onConnectExternal.removeListener(onPortConnect) + response(false) + return + } + tabId = tab.id + + response(true) + } catch (err) { + if (onPortConnect) { + chrome.runtime.onConnectExternal.removeListener(onPortConnect) + } + console.error("[ShareCommandToHub] Failed to open hub tab:", err) + response(false) + } + } + share() + return true +} + +function onMessageExternal( + message: Record, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, +): boolean { + if (!sender.origin || sender.origin !== hubOrigin) return false + + const { action, command, id } = message ?? {} + console.log("[onMessageExternal] Received message:", message) + + if (action === "AddCommand" && typeof command === "string") { + Ipc.callListener< + { command: string }, + { result: boolean; error?: string; client_id?: string } + >(BgCommand.addCommand, { command }) + .then(sendResponse) + .catch((err) => { + console.error("[onMessageExternal] AddCommand failed:", err) + sendResponse({ result: false, error: err?.message ?? "Unknown error" }) + }) + return true + } + + if (action === "DeleteCommand" && typeof id === "string") { + Ipc.callListener<{ id: string }, { result: boolean; error?: string }>( + BgCommand.removeCommand, + { id }, + ) + .then(sendResponse) + .catch((err) => { + console.error("[onMessageExternal] DeleteCommand failed:", err) + sendResponse({ result: false, error: err?.message ?? "Unknown error" }) + }) + return true + } + + if (action === "RequestInstalledCommand") { + Storage.getCommands() + .then((commands) => { + sendResponse({ + action: "SyncInstalledCommand", + installedIds: commands.map((c) => c.id), + }) + }) + .catch((err) => { + console.error( + "[onMessageExternal] RequestInstalledCommand failed:", + err, + ) + sendResponse({ action: "SyncInstalledCommand", installedIds: [] }) + }) + return true + } + + return false +} + +export function initHubExternalListener(): void { + chrome.runtime.onMessageExternal.addListener(onMessageExternal) +} diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts index f20e326e..d395b585 100644 --- a/packages/extension/src/services/hubShare.test.ts +++ b/packages/extension/src/services/hubShare.test.ts @@ -3,11 +3,21 @@ import { getHubLocale, toSubmitCommandInput, shareCommandToHub, - _resetShareState, } from "./hubShare" +import { Ipc, BgCommand } from "@/services/ipc" import { OPEN_MODE, PAGE_ACTION_OPEN_MODE } from "@/const" import type { SearchCommand, PageActionCommand, AiPromptCommand } from "@/types" +// Mock the IPC module so that shareCommandToHub does not trigger real messaging +vi.mock("@/services/ipc", () => ({ + Ipc: { + send: vi.fn().mockResolvedValue(true), + }, + BgCommand: { + shareCommandToHub: "shareCommandToHub", + }, +})) + // ---- Fixtures -------------------------------------------------------------- const baseCmd = { @@ -216,104 +226,69 @@ describe("toSubmitCommandInput", () => { // ---- shareCommandToHub ----------------------------------------------------- describe("shareCommandToHub", () => { - let mockHubWindow: { postMessage: ReturnType } - beforeEach(() => { vi.spyOn(chrome.i18n, "getUILanguage").mockReturnValue("en") - vi.useFakeTimers() - - mockHubWindow = { postMessage: vi.fn() } - vi.spyOn(window, "open").mockReturnValue(mockHubWindow as any) - vi.spyOn(window, "addEventListener") - vi.spyOn(window, "removeEventListener") + vi.mocked(Ipc.send).mockResolvedValue(true) }) afterEach(() => { - _resetShareState() vi.restoreAllMocks() - vi.useRealTimers() }) - it("SH-01: opens Hub window and returns true for a valid command", () => { + it("SH-01: calls Ipc.send with shareCommandToHub and returns true for a valid command", () => { const result = shareCommandToHub(makeSearchCmd()) expect(result).toBe(true) - expect(window.open).toHaveBeenCalledWith( - expect.stringContaining("/en/dashboard/commands"), - "_blank", + expect(Ipc.send).toHaveBeenCalledWith( + BgCommand.shareCommandToHub, + expect.objectContaining({ locale: "en" }), ) }) - it("SH-02: returns false when the command has no searchUrl", () => { + it("SH-02: returns false and does not call Ipc.send when the command has no searchUrl", () => { const result = shareCommandToHub(makeSearchCmd({ searchUrl: undefined })) expect(result).toBe(false) - expect(window.open).not.toHaveBeenCalled() - }) - - it("SH-03: returns false when window.open returns null", () => { - vi.mocked(window.open).mockReturnValue(null) - const result = shareCommandToHub(makeSearchCmd()) - expect(result).toBe(false) - }) - - it("SH-04: sends postMessage on each interval tick", () => { - shareCommandToHub(makeSearchCmd()) - vi.advanceTimersByTime(500) - expect(mockHubWindow.postMessage).toHaveBeenCalledTimes(1) - expect(mockHubWindow.postMessage).toHaveBeenCalledWith( - expect.objectContaining({ type: "share-command" }), - expect.any(String), - ) - vi.advanceTimersByTime(500) - expect(mockHubWindow.postMessage).toHaveBeenCalledTimes(2) - }) - - it("SH-05: stops retrying after receiving share-command-ack", () => { - const NEW_HUB_URL = - import.meta.env.VITE_NEW_HUB_URL ?? - "https://selection-command-hub.pages.dev" - - shareCommandToHub(makeSearchCmd()) - - const ackEvent = new MessageEvent("message", { - origin: NEW_HUB_URL, - data: { type: "share-command-ack" }, - }) - window.dispatchEvent(ackEvent) - - const beforeCount = mockHubWindow.postMessage.mock.calls.length - vi.advanceTimersByTime(2000) - expect(mockHubWindow.postMessage.mock.calls.length).toBe(beforeCount) + expect(Ipc.send).not.toHaveBeenCalled() }) it("SH-06: can share an AI_PROMPT command", () => { const result = shareCommandToHub(makeAiPromptCmd()) expect(result).toBe(true) - vi.advanceTimersByTime(500) - expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect(Ipc.send).toHaveBeenCalledWith( + BgCommand.shareCommandToHub, expect.objectContaining({ - type: "share-command", - command: expect.objectContaining({ - openMode: OPEN_MODE.AI_PROMPT, - targetUrl: "https://gemini.google.com/app", - }), + openMode: OPEN_MODE.AI_PROMPT, + targetUrl: "https://gemini.google.com/app", }), - expect.any(String), ) }) it("SH-07: can share a PAGE_ACTION command", () => { const result = shareCommandToHub(makePageActionCmd()) expect(result).toBe(true) - vi.advanceTimersByTime(500) - expect(mockHubWindow.postMessage).toHaveBeenCalledWith( + expect(Ipc.send).toHaveBeenCalledWith( + BgCommand.shareCommandToHub, expect.objectContaining({ - type: "share-command", - command: expect.objectContaining({ - openMode: OPEN_MODE.PAGE_ACTION, - targetUrl: "https://example.com", - }), + openMode: OPEN_MODE.PAGE_ACTION, + targetUrl: "https://example.com", }), - expect.any(String), + ) + }) + + it("SH-08: logs an error when Ipc.send rejects", async () => { + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined) + vi.mocked(Ipc.send).mockRejectedValue(new Error("network error")) + + shareCommandToHub(makeSearchCmd()) + + // Allow microtasks to flush so the .catch() handler runs + await Promise.resolve() + + expect(consoleSpy).toHaveBeenCalledWith( + "[HubShare] Failed to share command:", + expect.any(Error), ) }) }) + diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 052d6ac5..636124a7 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -1,10 +1,10 @@ import { - NEW_HUB_URL, NEW_HUB_SUPPORTED_LOCALES, type NewHubLocale, } from "@/const" import { getAiServicesFallback } from "@/services/aiPromptFallback" import { isAiPromptCommand, isPageActionCommand } from "@/lib/utils" +import { Ipc, BgCommand } from "@/services/ipc" import type { SelectionCommand, SearchCommand } from "@/types" // ---- Type definitions ------------------------------------------------------ @@ -65,19 +65,7 @@ export function toSubmitCommandInput( // ---- Share main logic ------------------------------------------------------ -const RETRY_INTERVAL_MS = 500 -const MAX_RETRIES = 20 // 10 seconds - -let isSharing = false - -// Exposed for testing purposes to reset the sharing state between tests -export function _resetShareState(): void { - isSharing = false -} - export function shareCommandToHub(command: SelectionCommand): boolean { - if (isSharing) return false - const input = toSubmitCommandInput(command) if (!input) { console.warn( @@ -86,57 +74,8 @@ export function shareCommandToHub(command: SelectionCommand): boolean { return false } - const hubUrl = `${NEW_HUB_URL}/${input.locale}/dashboard/commands` - const hubWindow = window.open(hubUrl, "_blank") - if (!hubWindow) { - console.error("[HubShare] Failed to open Hub page.") - return false - } - - isSharing = true - let retries = 0 - - const cleanup = () => { - clearInterval(timer) - window.removeEventListener("message", onAck) - isSharing = false - } - - // Stop retrying once an ack is received from the Hub - const onAck = (event: MessageEvent) => { - if (event.origin !== NEW_HUB_URL) return - if ((event.data as { type?: string })?.type === "share-command-ack") { - cleanup() - } - } - window.addEventListener("message", onAck) - - const timer = setInterval(() => { - retries++ - if (hubWindow.closed) { - cleanup() - return - } - if (retries > MAX_RETRIES) { - cleanup() - console.error("[HubShare] Hub page did not respond in time.") - return - } - try { - hubWindow.postMessage( - { type: "share-command", command: input }, - NEW_HUB_URL, - ) - // Keep the interval running until ack is received - } catch (err) { - if (err instanceof DOMException) { - // Hub window still loading — retry on next tick - } else { - cleanup() - console.error("[HubShare] Unexpected error during postMessage:", err) - } - } - }, RETRY_INTERVAL_MS) - + void Ipc.send(BgCommand.shareCommandToHub, input).catch((err) => { + console.error("[HubShare] Failed to share command:", err) + }) return true } diff --git a/packages/extension/src/services/ipc.ts b/packages/extension/src/services/ipc.ts index b23cbba3..6cc88691 100644 --- a/packages/extension/src/services/ipc.ts +++ b/packages/extension/src/services/ipc.ts @@ -32,6 +32,8 @@ export enum BgCommand { getTabId = "getTabId", getActiveTabId = "getActiveTabId", setClipboard = "setClipboard", + // Hub + shareCommandToHub = "shareCommandToHub", // PageAction addPageAction = "addPageAction", updatePageAction = "updatePageAction", diff --git a/packages/extension/src/test/setup.ts b/packages/extension/src/test/setup.ts index ed6089d7..da9cbc77 100644 --- a/packages/extension/src/test/setup.ts +++ b/packages/extension/src/test/setup.ts @@ -289,6 +289,10 @@ global.chrome = { addListener: vi.fn(), removeListener: vi.fn(), }, + onMessageExternal: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, onConnect: { addListener: vi.fn(), removeListener: vi.fn(), diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 07e62fb3..cf68f4c7 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -19,9 +19,26 @@ export default defineConfig(({ mode }) => { const isWatchMode = process.argv.includes("--watch") loadEnv(mode, process.cwd(), "") + const isProduction = mode === "production" + const activeManifest = isProduction + ? { + ...manifest, + content_scripts: manifest.content_scripts.map((cs) => ({ + ...cs, + matches: cs.matches.filter((m) => !m.includes("localhost")), + })), + externally_connectable: { + ...manifest.externally_connectable, + matches: manifest.externally_connectable.matches.filter( + (m) => !m.includes("localhost"), + ), + }, + } + : manifest + const plugins = [ react(), - crx({ manifest }), + crx({ manifest: activeManifest }), ...importIfPlugin({ mode }), viteTouchCss({ cssFilePaths: [path.resolve(__dirname, "src/components/App.css")],