From b77d4109023538cffa20d7790a51bedaa6a8c4f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 07:19:17 +0000 Subject: [PATCH 01/18] Initial plan From 43d5dc9a029b12b650b644640a580089aa95e079 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 07:31:53 +0000 Subject: [PATCH 02/18] feat: improve hub communication via background_script Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/65b033d3-5918-49ab-acd3-a98f333765b1 Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/manifest.json | 8 ++ packages/extension/src/background_script.ts | 21 ++++ .../src/hooks/useCommandHubBridge.ts | 104 ++++++++++++++++++ packages/extension/src/new_command_hub.tsx | 12 ++ .../extension/src/services/hubShare.test.ts | 96 +++++----------- packages/extension/src/services/hubShare.ts | 67 +---------- packages/extension/src/services/ipc.ts | 2 + .../extension/src/services/storage/const.ts | 1 + 8 files changed, 177 insertions(+), 134 deletions(-) create mode 100644 packages/extension/src/hooks/useCommandHubBridge.ts create mode 100644 packages/extension/src/new_command_hub.tsx diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index d5dee570..e0c60432 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -25,6 +25,14 @@ "js": [ "src/command_hub.tsx" ] + }, + { + "matches": [ + "https://selection-command-hub.pages.dev/*" + ], + "js": [ + "src/new_command_hub.tsx" + ] } ], "background": { diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index fc651649..d6d4bd4e 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -3,6 +3,7 @@ import { OPTION_PAGE_PATH, SHORTCUT_NO_SELECTION_BEHAVIOR, HUB_URL, + NEW_HUB_URL, SCREEN, COMMAND_SOURCE_TYPE, } from "@/const" @@ -29,6 +30,7 @@ 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 type { SubmitCommandInput } from "@/services/hubShare" import { importIf } from "@import-if" importIf("production", "./lib/sentry/initialize") @@ -401,6 +403,25 @@ const commandFuncs = { [BgCommand.getTabId]: getTabId, [BgCommand.getActiveTabId]: getActiveTabId, + // + // Hub + // + [BgCommand.shareCommandToHub]: ( + param: SubmitCommandInput, + _: Sender, + response: (res: unknown) => void, + ): boolean => { + const share = async () => { + // Store the command so the hub content script can pick it up after load + await Storage.set(SESSION_STORAGE_KEY.HUB_SHARE_PENDING, param) + const hubUrl = `${NEW_HUB_URL}/${param.locale}/dashboard/commands` + chrome.tabs.create({ url: hubUrl }) + response(true) + } + share() + return true + }, + // // PageAction // diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts new file mode 100644 index 00000000..cf5d4c27 --- /dev/null +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -0,0 +1,104 @@ +import { useEffect } from "react" +import { Ipc, BgCommand } from "@/services/ipc" +import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import type { SubmitCommandInput } from "@/services/hubShare" + +// Retry settings for the share-command postMessage loop +const RETRY_INTERVAL_MS = 500 +const MAX_RETRIES = 20 // 10 seconds + +/** + * Sends a pending share command to the hub page via postMessage and waits for + * the hub's acknowledgement. Retries at fixed intervals until ack or timeout. + */ +function sendShareCommand(command: SubmitCommandInput): () => void { + let retries = 0 + + const cleanup = () => { + clearInterval(timer) + window.removeEventListener("message", onAck) + } + + // Stop retrying once the hub responds with an ack + const onAck = (event: MessageEvent) => { + if ((event.data as { type?: string })?.type === "share-command-ack") { + cleanup() + } + } + window.addEventListener("message", onAck) + + const timer = setInterval(() => { + retries++ + if (retries > MAX_RETRIES) { + cleanup() + console.error("[HubBridge] Hub page did not respond to share-command in time.") + return + } + // Post to the hub page (same origin — content script shares the page window) + window.postMessage({ type: "share-command", command }, location.origin) + }, RETRY_INTERVAL_MS) + + return cleanup +} + +/** + * React hook that bridges the new Selection Command Hub page with the + * extension's background script. + * + * Responsibilities: + * 1. On mount, reads any pending share command stored by the background script + * in session storage and forwards it to the hub page via postMessage. + * 2. Listens for AddCommand / DeleteCommand messages originating from the hub + * page and relays them to the background script via IPC. + * + * This hook is intended for use in the content script injected into the new hub + * (new_command_hub.tsx). + */ +export function useCommandHubBridge(): void { + // Forward any command that the background script stored for sharing + useEffect(() => { + let cleanupShare: (() => void) | undefined + + const handlePendingShare = async () => { + const pending = await Storage.get( + SESSION_STORAGE_KEY.HUB_SHARE_PENDING, + ) + if (!pending) return + + // Clear immediately so a reload does not re-send the same command + await Storage.set(SESSION_STORAGE_KEY.HUB_SHARE_PENDING, null) + + cleanupShare = sendShareCommand(pending) + } + + handlePendingShare() + + return () => { + cleanupShare?.() + } + }, []) + + // Relay hub-page messages (add / delete commands) to the background script + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Only accept messages from the hub page itself + if (event.origin !== location.origin) return + + const { action, command, id } = (event.data ?? {}) as Record< + string, + unknown + > + + if (action === "AddCommand") { + if (typeof command !== "string") return + Ipc.send(BgCommand.addCommand, { command }) + } else if (action === "DeleteCommand") { + if (typeof id !== "string") return + Ipc.send(BgCommand.removeCommand, { id }) + } + } + + 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/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts index f20e326e..a6a8186b 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,52 @@ 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).mockClear() }) 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), ) }) }) + diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 052d6ac5..5ce12f9b 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,6 @@ 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) - + Ipc.send(BgCommand.shareCommandToHub, input) 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/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index 6f78c9cc..a1fb4100 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -29,6 +29,7 @@ export enum SESSION_STORAGE_KEY { PA_CONTEXT = "pageActionContext", PA_RECORDER_OPTION = "pageActionRecorderOption", PA_SIDE_PANEL_PENDING = "pageActionSidePanelPending", + HUB_SHARE_PENDING = "hubSharePending", } export type KEY = From 27e79076f3897f06420124b438268577df9971b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 07:34:03 +0000 Subject: [PATCH 03/18] fix: address code review feedback on useCommandHubBridge and background_script Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/65b033d3-5918-49ab-acd3-a98f333765b1 Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/background_script.ts | 15 ++++++++----- .../src/hooks/useCommandHubBridge.ts | 22 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index d6d4bd4e..b3b4cbc8 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -412,11 +412,16 @@ const commandFuncs = { response: (res: unknown) => void, ): boolean => { const share = async () => { - // Store the command so the hub content script can pick it up after load - await Storage.set(SESSION_STORAGE_KEY.HUB_SHARE_PENDING, param) - const hubUrl = `${NEW_HUB_URL}/${param.locale}/dashboard/commands` - chrome.tabs.create({ url: hubUrl }) - response(true) + try { + // Store the command so the hub content script can pick it up after load + await Storage.set(SESSION_STORAGE_KEY.HUB_SHARE_PENDING, param) + const hubUrl = `${NEW_HUB_URL}/${param.locale}/dashboard/commands` + chrome.tabs.create({ url: hubUrl }) + response(true) + } catch (err) { + console.error("[ShareCommandToHub] Failed to open hub tab:", err) + response(false) + } } share() return true diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index cf5d4c27..59b55a86 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -8,10 +8,11 @@ const RETRY_INTERVAL_MS = 500 const MAX_RETRIES = 20 // 10 seconds /** - * Sends a pending share command to the hub page via postMessage and waits for - * the hub's acknowledgement. Retries at fixed intervals until ack or timeout. + * Starts sending a share command to the hub page via postMessage, retrying at + * fixed intervals until the hub acknowledges or MAX_RETRIES is exceeded. + * Returns a cleanup function that cancels any in-flight retries. */ -function sendShareCommand(command: SubmitCommandInput): () => void { +function startShareCommandWithRetry(command: SubmitCommandInput): () => void { let retries = 0 const cleanup = () => { @@ -21,6 +22,8 @@ function sendShareCommand(command: SubmitCommandInput): () => void { // Stop retrying once the hub responds with an ack const onAck = (event: MessageEvent) => { + // Accept only same-origin messages from the hub page itself + if (event.source !== window) return if ((event.data as { type?: string })?.type === "share-command-ack") { cleanup() } @@ -68,7 +71,7 @@ export function useCommandHubBridge(): void { // Clear immediately so a reload does not re-send the same command await Storage.set(SESSION_STORAGE_KEY.HUB_SHARE_PENDING, null) - cleanupShare = sendShareCommand(pending) + cleanupShare = startShareCommandWithRetry(pending) } handlePendingShare() @@ -81,7 +84,8 @@ export function useCommandHubBridge(): void { // Relay hub-page messages (add / delete commands) to the background script useEffect(() => { const handleMessage = (event: MessageEvent) => { - // Only accept messages from the hub page itself + // Only accept messages from the hub page itself (not injected scripts or iframes) + if (event.source !== window) return if (event.origin !== location.origin) return const { action, command, id } = (event.data ?? {}) as Record< @@ -91,10 +95,14 @@ export function useCommandHubBridge(): void { if (action === "AddCommand") { if (typeof command !== "string") return - Ipc.send(BgCommand.addCommand, { command }) + Ipc.send(BgCommand.addCommand, { command }).catch((err) => { + console.error("[HubBridge] Failed to add command:", err) + }) } else if (action === "DeleteCommand") { if (typeof id !== "string") return - Ipc.send(BgCommand.removeCommand, { id }) + Ipc.send(BgCommand.removeCommand, { id }).catch((err) => { + console.error("[HubBridge] Failed to delete command:", err) + }) } } From 5216e199cd98716026d428ed5afca8386348d6d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 07:35:40 +0000 Subject: [PATCH 04/18] fix: add error handling for async calls in HubBridge and background_script Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/65b033d3-5918-49ab-acd3-a98f333765b1 Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/background_script.ts | 4 +++- packages/extension/src/hooks/useCommandHubBridge.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index b3b4cbc8..c63f1c61 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -423,7 +423,9 @@ const commandFuncs = { response(false) } } - share() + share().catch((err) => { + console.error("[ShareCommandToHub] Unhandled error:", err) + }) return true }, diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index 59b55a86..20523425 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -74,7 +74,9 @@ export function useCommandHubBridge(): void { cleanupShare = startShareCommandWithRetry(pending) } - handlePendingShare() + handlePendingShare().catch((err) => { + console.error("[HubBridge] Failed to process pending share:", err) + }) return () => { cleanupShare?.() From 9763e2118ddc124cea5008df69b5198351790bda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 09:26:00 +0000 Subject: [PATCH 05/18] feat: use externally_connectable and onMessageExternal for hub messaging Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/8f2f64d0-4c83-45d3-8ae8-8c0516661c64 Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/manifest.json | 5 ++ packages/extension/src/background_script.ts | 35 ++++++++++++++ .../src/hooks/useCommandHubBridge.ts | 46 +++---------------- packages/extension/src/test/setup.ts | 3 ++ 4 files changed, 49 insertions(+), 40 deletions(-) diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index e0c60432..1913dc6f 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -38,6 +38,11 @@ "background": { "service_worker": "src/background_script.ts" }, + "externally_connectable": { + "matches": [ + "https://selection-command-hub.pages.dev/*" + ] + }, "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 c63f1c61..cb8d05ea 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -448,6 +448,41 @@ for (const key in BgCommand) { Ipc.addListener(command, commandFuncs[key]) } +// Receive messages directly from the new hub page (external webpage messaging). +// The hub page calls chrome.runtime.sendMessage(extensionId, message) to relay +// AddCommand / DeleteCommand actions without going through a content script. +chrome.runtime.onMessageExternal.addListener( + ( + message: Record, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, + ) => { + // Verify that the sender originates from the new hub + const hubOrigin = new URL(NEW_HUB_URL).origin + if (!sender.origin || sender.origin !== hubOrigin) return false + + const { action, command, id } = message ?? {} + + if (action === "AddCommand" && typeof command === "string") { + // Reuse the existing addCommand listener already registered via Ipc + Ipc.callListener<{ command: string }, boolean>(BgCommand.addCommand, { + command, + }).then(sendResponse) + return true // async response + } + + if (action === "DeleteCommand" && typeof id === "string") { + // Reuse the existing removeCommand listener already registered via Ipc + Ipc.callListener<{ id: string }, boolean>(BgCommand.removeCommand, { + id, + }).then(sendResponse) + return true // async response + } + + return false + }, +) + const updateWindowSize = async ( commandId: string, width: number, diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index 20523425..2da50e0e 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -1,5 +1,4 @@ import { useEffect } from "react" -import { Ipc, BgCommand } from "@/services/ipc" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" import type { SubmitCommandInput } from "@/services/hubShare" @@ -45,20 +44,16 @@ function startShareCommandWithRetry(command: SubmitCommandInput): () => void { } /** - * React hook that bridges the new Selection Command Hub page with the - * extension's background script. + * React hook for the new Selection Command Hub content script. * - * Responsibilities: - * 1. On mount, reads any pending share command stored by the background script - * in session storage and forwards it to the hub page via postMessage. - * 2. Listens for AddCommand / DeleteCommand messages originating from the hub - * page and relays them to the background script via IPC. + * On mount, reads any pending share command stored by the background script + * in session storage and forwards it to the hub page via postMessage. * - * This hook is intended for use in the content script injected into the new hub - * (new_command_hub.tsx). + * AddCommand / DeleteCommand messages from the hub page are sent directly to + * the background script via chrome.runtime.sendMessage (externally_connectable), + * so this hook does not need to relay them. */ export function useCommandHubBridge(): void { - // Forward any command that the background script stored for sharing useEffect(() => { let cleanupShare: (() => void) | undefined @@ -82,33 +77,4 @@ export function useCommandHubBridge(): void { cleanupShare?.() } }, []) - - // Relay hub-page messages (add / delete commands) to the background script - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - // Only accept messages from the hub page itself (not injected scripts or iframes) - if (event.source !== window) return - if (event.origin !== location.origin) return - - const { action, command, id } = (event.data ?? {}) as Record< - string, - unknown - > - - if (action === "AddCommand") { - if (typeof command !== "string") return - Ipc.send(BgCommand.addCommand, { command }).catch((err) => { - console.error("[HubBridge] Failed to add command:", err) - }) - } else if (action === "DeleteCommand") { - if (typeof id !== "string") return - Ipc.send(BgCommand.removeCommand, { id }).catch((err) => { - console.error("[HubBridge] Failed to delete command:", err) - }) - } - } - - window.addEventListener("message", handleMessage) - return () => window.removeEventListener("message", handleMessage) - }, []) } diff --git a/packages/extension/src/test/setup.ts b/packages/extension/src/test/setup.ts index ed6089d7..b371360d 100644 --- a/packages/extension/src/test/setup.ts +++ b/packages/extension/src/test/setup.ts @@ -289,6 +289,9 @@ global.chrome = { addListener: vi.fn(), removeListener: vi.fn(), }, + onMessageExternal: { + addListener: vi.fn(), + }, onConnect: { addListener: vi.fn(), removeListener: vi.fn(), From 161005d167e4ebf9cfe6cb3f8f55647b52e4f895 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 09:27:47 +0000 Subject: [PATCH 06/18] fix: add error handling for onMessageExternal IPC calls and removeListener mock Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/8f2f64d0-4c83-45d3-8ae8-8c0516661c64 Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/background_script.ts | 14 ++++++++++++-- packages/extension/src/test/setup.ts | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index cb8d05ea..ecaee058 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -467,7 +467,12 @@ chrome.runtime.onMessageExternal.addListener( // Reuse the existing addCommand listener already registered via Ipc Ipc.callListener<{ command: string }, boolean>(BgCommand.addCommand, { command, - }).then(sendResponse) + }) + .then(sendResponse) + .catch((err) => { + console.error("[onMessageExternal] AddCommand failed:", err) + sendResponse(false) + }) return true // async response } @@ -475,7 +480,12 @@ chrome.runtime.onMessageExternal.addListener( // Reuse the existing removeCommand listener already registered via Ipc Ipc.callListener<{ id: string }, boolean>(BgCommand.removeCommand, { id, - }).then(sendResponse) + }) + .then(sendResponse) + .catch((err) => { + console.error("[onMessageExternal] DeleteCommand failed:", err) + sendResponse(false) + }) return true // async response } diff --git a/packages/extension/src/test/setup.ts b/packages/extension/src/test/setup.ts index b371360d..da9cbc77 100644 --- a/packages/extension/src/test/setup.ts +++ b/packages/extension/src/test/setup.ts @@ -291,6 +291,7 @@ global.chrome = { }, onMessageExternal: { addListener: vi.fn(), + removeListener: vi.fn(), }, onConnect: { addListener: vi.fn(), From 97798961b0f046d76f806a1bae2547403de460e5 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 9 May 2026 14:17:49 +0900 Subject: [PATCH 07/18] Fix: Enable adding and deleting commands to work correctly. --- packages/extension/manifest.json | 8 +- packages/extension/src/background_script.ts | 27 ++- .../src/components/commandHub/CommandHub.tsx | 3 - .../src/hooks/useCommandHubBridge.ts | 173 +----------------- 4 files changed, 34 insertions(+), 177 deletions(-) diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 1913dc6f..6f6a48f3 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -28,7 +28,9 @@ }, { "matches": [ - "https://selection-command-hub.pages.dev/*" + "https://selection-command.com/*", + "https://selection-command-hub.siro-cola.workers.dev/*", + "http://localhost:3000/*" ], "js": [ "src/new_command_hub.tsx" @@ -40,7 +42,9 @@ }, "externally_connectable": { "matches": [ - "https://selection-command-hub.pages.dev/*" + "https://selection-command.com/*", + "https://selection-command-hub.siro-cola.workers.dev/*", + "http://localhost:3000/*" ] }, "options_page": "src/options_page.html", diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index ecaee058..3910e49a 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -213,7 +213,7 @@ const commandFuncs = { if (!cmd) { console.error("invalid command", param.command) - response(false) + response({ result: false, error: "Invalid command format" }) return true } @@ -230,7 +230,7 @@ const commandFuncs = { ) }) .then(() => { - response(true) + response({ result: true, install_id: cmd.id }) }) return true }, @@ -246,7 +246,7 @@ const commandFuncs = { if (commandToRemove) { const newCommands = current.filter((c) => c.id !== param.id) if (newCommands.length === current.length) { - response(false) + response({ result: false, error: "Command not found" }) return } await Storage.setCommands(newCommands) @@ -258,7 +258,7 @@ const commandFuncs = { SCREEN.COMMAND_HUB, ) } - response(true) + response({ result: true }) } remove() return true @@ -462,6 +462,7 @@ chrome.runtime.onMessageExternal.addListener( 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") { // Reuse the existing addCommand listener already registered via Ipc @@ -489,6 +490,24 @@ chrome.runtime.onMessageExternal.addListener( return true // async response } + 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 // async response + } + return false }, ) 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/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index 1febf223..e6c2dcd6 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -1,13 +1,7 @@ -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" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" import type { SubmitCommandInput } from "@/services/hubShare" @@ -43,7 +37,9 @@ function startShareCommandWithRetry(command: SubmitCommandInput): () => void { retries++ if (retries > MAX_RETRIES) { cleanup() - console.error("[HubBridge] Hub page did not respond to share-command in time.") + console.error( + "[HubBridge] Hub page did not respond to share-command in time.", + ) return } // Post to the hub page (same origin — content script shares the page window) @@ -53,106 +49,9 @@ function startShareCommandWithRetry(command: SubmitCommandInput): () => void { return cleanup } -/** - * 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 @@ -189,66 +88,4 @@ export function useCommandHubBridge() { cleanupShare?.() } }, []) - - 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) - } - }, []) } From 6c34838929b9885574517d04ba81ad82ea80179d Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 9 May 2026 14:40:01 +0900 Subject: [PATCH 08/18] Update: Refactoring. --- .../option/editor/CommandEditDialog.tsx | 15 +- .../editor/commandChangedDetector.test.ts | 154 ++++++++++++++++++ .../option/editor/commandChangedDetector.ts | 27 +++ 3 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 packages/extension/src/components/option/editor/commandChangedDetector.test.ts create mode 100644 packages/extension/src/components/option/editor/commandChangedDetector.ts 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 +} From 773351dfca4f40db7c1fd519d1b3eb7498d2e362 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 9 May 2026 18:59:37 +0900 Subject: [PATCH 09/18] Update: Enable sharing commands via port.postMessage. --- packages/extension/src/background_script.ts | 57 +++++++++++++-- .../src/hooks/useCommandHubBridge.ts | 69 ------------------- .../extension/src/services/storage/const.ts | 1 - 3 files changed, 51 insertions(+), 76 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 3910e49a..ac57e32e 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -411,21 +411,66 @@ const commandFuncs = { _: Sender, response: (res: unknown) => void, ): boolean => { + // Retry settings for the share-command postMessage loop + const RETRY_INTERVAL_MS = 500 + const MAX_RETRIES = 20 // 10 seconds + let retries = 0 + const share = async () => { try { - // Store the command so the hub content script can pick it up after load - await Storage.set(SESSION_STORAGE_KEY.HUB_SHARE_PENDING, param) + // Prepare to send the command to the hub page via port.postMessage. + // We need to wait for the hub page to connect. + const onConnect = (port: chrome.runtime.Port) => { + if (port.name !== "hub-share") return + if (port.sender?.tab?.id !== tab.id) return + + chrome.runtime.onConnectExternal.removeListener(onConnect) + + 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) + + // Start the loop to post the command to the hub page until we receive an ack or exceed max retries. + const timer = setInterval(() => { + retries++ + if (retries > MAX_RETRIES) { + cleanup() + console.error( + "[Hub] Hub page did not respond to share-command in time.", + ) + return + } + // Post to the hub page. + port.postMessage({ type: "share-command", command: param }) + }, RETRY_INTERVAL_MS) + } + chrome.runtime.onConnectExternal.addListener(onConnect) + + // Open the command share page on Hub. const hubUrl = `${NEW_HUB_URL}/${param.locale}/dashboard/commands` - chrome.tabs.create({ url: hubUrl }) + const tab = await new Promise((resolve) => + chrome.tabs.create({ url: hubUrl }, resolve), + ) + if (!tab?.id) { + response(false) + return + } + response(true) } catch (err) { console.error("[ShareCommandToHub] Failed to open hub tab:", err) response(false) } } - share().catch((err) => { - console.error("[ShareCommandToHub] Unhandled error:", err) - }) + share() return true }, diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index e6c2dcd6..e133623b 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -2,53 +2,9 @@ import { useEffect } from "react" import { useSection } from "@/hooks/useSettings" import { CACHE_SECTIONS } from "@/services/settings/settingsCache" import { NEW_HUB_URL } from "@/const" -import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" -import type { SubmitCommandInput } from "@/services/hubShare" const hubOrigin = new URL(NEW_HUB_URL).origin -// Retry settings for the share-command postMessage loop -const RETRY_INTERVAL_MS = 500 -const MAX_RETRIES = 20 // 10 seconds - -/** - * Starts sending a share command to the hub page via postMessage, retrying at - * fixed intervals until the hub acknowledges or MAX_RETRIES is exceeded. - * Returns a cleanup function that cancels any in-flight retries. - */ -function startShareCommandWithRetry(command: SubmitCommandInput): () => void { - let retries = 0 - - const cleanup = () => { - clearInterval(timer) - window.removeEventListener("message", onAck) - } - - // Stop retrying once the hub responds with an ack - const onAck = (event: MessageEvent) => { - if (event.origin !== hubOrigin) return - if ((event.data as { type?: string })?.type === "share-command-ack") { - cleanup() - } - } - window.addEventListener("message", onAck) - - const timer = setInterval(() => { - retries++ - if (retries > MAX_RETRIES) { - cleanup() - console.error( - "[HubBridge] Hub page did not respond to share-command in time.", - ) - return - } - // Post to the hub page (same origin — content script shares the page window) - window.postMessage({ type: "share-command", command }, hubOrigin) - }, RETRY_INTERVAL_MS) - - return cleanup -} - export function useCommandHubBridge() { const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) @@ -63,29 +19,4 @@ export function useCommandHubBridge() { hubOrigin, ) }, [commands]) - - // Forward any pending share command stored by the background script in session storage. - useEffect(() => { - let cleanupShare: (() => void) | undefined - - const handlePendingShare = async () => { - const pending = await Storage.get( - SESSION_STORAGE_KEY.HUB_SHARE_PENDING, - ) - if (!pending) return - - // Clear immediately so a reload does not re-send the same command - await Storage.set(SESSION_STORAGE_KEY.HUB_SHARE_PENDING, null) - - cleanupShare = startShareCommandWithRetry(pending) - } - - handlePendingShare().catch((err) => { - console.error("[HubBridge] Failed to process pending share:", err) - }) - - return () => { - cleanupShare?.() - } - }, []) } diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index a1fb4100..6f78c9cc 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -29,7 +29,6 @@ export enum SESSION_STORAGE_KEY { PA_CONTEXT = "pageActionContext", PA_RECORDER_OPTION = "pageActionRecorderOption", PA_SIDE_PANEL_PENDING = "pageActionSidePanelPending", - HUB_SHARE_PENDING = "hubSharePending", } export type KEY = From 6245089625334a26d4698d9ace9c7cbbd9cfbea3 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 9 May 2026 21:40:11 +0900 Subject: [PATCH 10/18] Update: Separate HUB-related logic into a different file. --- packages/extension/src/background_script.ts | 135 +----- .../src/services/hub/background.test.ts | 383 ++++++++++++++++++ .../extension/src/services/hub/background.ts | 128 ++++++ 3 files changed, 514 insertions(+), 132 deletions(-) create mode 100644 packages/extension/src/services/hub/background.test.ts create mode 100644 packages/extension/src/services/hub/background.ts diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index ac57e32e..b073d86d 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -3,7 +3,6 @@ import { OPTION_PAGE_PATH, SHORTCUT_NO_SELECTION_BEHAVIOR, HUB_URL, - NEW_HUB_URL, SCREEN, COMMAND_SOURCE_TYPE, } from "@/const" @@ -30,7 +29,7 @@ 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 type { SubmitCommandInput } from "@/services/hubShare" +import * as HubBackground from "@/services/hub/background" import { importIf } from "@import-if" importIf("production", "./lib/sentry/initialize") @@ -406,73 +405,7 @@ const commandFuncs = { // // Hub // - [BgCommand.shareCommandToHub]: ( - param: SubmitCommandInput, - _: Sender, - response: (res: unknown) => void, - ): boolean => { - // Retry settings for the share-command postMessage loop - const RETRY_INTERVAL_MS = 500 - const MAX_RETRIES = 20 // 10 seconds - let retries = 0 - - const share = async () => { - try { - // Prepare to send the command to the hub page via port.postMessage. - // We need to wait for the hub page to connect. - const onConnect = (port: chrome.runtime.Port) => { - if (port.name !== "hub-share") return - if (port.sender?.tab?.id !== tab.id) return - - chrome.runtime.onConnectExternal.removeListener(onConnect) - - 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) - - // Start the loop to post the command to the hub page until we receive an ack or exceed max retries. - const timer = setInterval(() => { - retries++ - if (retries > MAX_RETRIES) { - cleanup() - console.error( - "[Hub] Hub page did not respond to share-command in time.", - ) - return - } - // Post to the hub page. - port.postMessage({ type: "share-command", command: param }) - }, RETRY_INTERVAL_MS) - } - chrome.runtime.onConnectExternal.addListener(onConnect) - - // Open the command share page on Hub. - const hubUrl = `${NEW_HUB_URL}/${param.locale}/dashboard/commands` - const tab = await new Promise((resolve) => - chrome.tabs.create({ url: hubUrl }, resolve), - ) - if (!tab?.id) { - response(false) - return - } - - response(true) - } catch (err) { - console.error("[ShareCommandToHub] Failed to open hub tab:", err) - response(false) - } - } - share() - return true - }, + [BgCommand.shareCommandToHub]: HubBackground.shareCommandToHub, // // PageAction @@ -493,69 +426,7 @@ for (const key in BgCommand) { Ipc.addListener(command, commandFuncs[key]) } -// Receive messages directly from the new hub page (external webpage messaging). -// The hub page calls chrome.runtime.sendMessage(extensionId, message) to relay -// AddCommand / DeleteCommand actions without going through a content script. -chrome.runtime.onMessageExternal.addListener( - ( - message: Record, - sender: chrome.runtime.MessageSender, - sendResponse: (response?: unknown) => void, - ) => { - // Verify that the sender originates from the new hub - const hubOrigin = new URL(NEW_HUB_URL).origin - 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") { - // Reuse the existing addCommand listener already registered via Ipc - Ipc.callListener<{ command: string }, boolean>(BgCommand.addCommand, { - command, - }) - .then(sendResponse) - .catch((err) => { - console.error("[onMessageExternal] AddCommand failed:", err) - sendResponse(false) - }) - return true // async response - } - - if (action === "DeleteCommand" && typeof id === "string") { - // Reuse the existing removeCommand listener already registered via Ipc - Ipc.callListener<{ id: string }, boolean>(BgCommand.removeCommand, { - id, - }) - .then(sendResponse) - .catch((err) => { - console.error("[onMessageExternal] DeleteCommand failed:", err) - sendResponse(false) - }) - return true // async response - } - - 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 // async response - } - - return false - }, -) +HubBackground.initHubExternalListener() const updateWindowSize = async ( commandId: string, 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..d866b0e7 --- /dev/null +++ b/packages/extension/src/services/hub/background.test.ts @@ -0,0 +1,383 @@ +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(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(false)) + }) + + 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(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(false)) + }) + + 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) 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)) + }) + + 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 `tab` 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..00d32cbb --- /dev/null +++ b/packages/extension/src/services/hub/background.ts @@ -0,0 +1,128 @@ +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 + +export const shareCommandToHub = ( + param: SubmitCommandInput, + _: Sender, + response: (res: unknown) => void, +): boolean => { + let retries = 0 + + const share = async () => { + try { + const onPortConnect = (port: chrome.runtime.Port) => { + if (port.name !== "hub-share") return + if (port.sender?.tab?.id !== tab.id) return + + chrome.runtime.onConnectExternal.removeListener(onPortConnect) + + 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) { + response(false) + return + } + + response(true) + } catch (err) { + 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 { + const hubOrigin = new URL(NEW_HUB_URL).origin + 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 }, boolean>(BgCommand.addCommand, { + command, + }) + .then(sendResponse) + .catch((err) => { + console.error("[onMessageExternal] AddCommand failed:", err) + sendResponse(false) + }) + return true + } + + if (action === "DeleteCommand" && typeof id === "string") { + Ipc.callListener<{ id: string }, boolean>(BgCommand.removeCommand, { id }) + .then(sendResponse) + .catch((err) => { + console.error("[onMessageExternal] DeleteCommand failed:", err) + sendResponse(false) + }) + 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) +} From 307b775817281393b5d09bd77651056900eff2e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 03:47:02 +0000 Subject: [PATCH 11/18] fix: use let tabId to avoid ReferenceError in onPortConnect and fix removeListener leak Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/07c9bfe1-1e50-452b-a1a7-2fbe147c45fc Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/services/hub/background.test.ts | 2 +- packages/extension/src/services/hub/background.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index d866b0e7..9379dbcb 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -318,7 +318,7 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) - // Let the async share() function resume past the await so `tab` is initialized + // Let the async share() function resume past the await so `tabId` is initialized await Promise.resolve() const portConnectListener = vi.mocked( diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 00d32cbb..e92c1051 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -13,12 +13,15 @@ export const shareCommandToHub = ( 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 const share = async () => { try { const onPortConnect = (port: chrome.runtime.Port) => { if (port.name !== "hub-share") return - if (port.sender?.tab?.id !== tab.id) return + if (port.sender?.tab?.id !== tabId) return chrome.runtime.onConnectExternal.removeListener(onPortConnect) @@ -55,9 +58,11 @@ export const shareCommandToHub = ( chrome.tabs.create({ url: hubUrl }, resolve), ) if (!tab?.id) { + chrome.runtime.onConnectExternal.removeListener(onPortConnect) response(false) return } + tabId = tab.id response(true) } catch (err) { From ded9a5659d245139fea49e881af3c3e98f5a1072 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 03:53:52 +0000 Subject: [PATCH 12/18] fix: address code review feedback on background.ts and background_script.ts Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/4e5a50d5-b06a-4da9-843f-82bba728b6dd Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/background_script.ts | 33 +++++++++---------- .../src/services/hub/background.test.ts | 21 +++++++++--- .../extension/src/services/hub/background.ts | 13 +++++--- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index b073d86d..9c9cde81 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -28,7 +28,7 @@ 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" @@ -228,8 +228,9 @@ const commandFuncs = { SCREEN.COMMAND_HUB, ) }) - .then(() => { - response({ result: true, install_id: cmd.id }) + .then(async () => { + const installId = await getOrCreateClientId() + response({ result: true, install_id: installId }) }) return true }, @@ -242,21 +243,19 @@ 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({ result: false, error: "Command not found" }) - 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 } + 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() diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index 9379dbcb..403c965e 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -119,7 +119,7 @@ describe("onMessageExternal - AddCommand", () => { ) }) - it("AC-02: calls sendResponse(false) when Ipc.callListener rejects", async () => { + 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() @@ -128,7 +128,12 @@ describe("onMessageExternal - AddCommand", () => { { origin: HUB_ORIGIN }, sendResponse, ) - await vi.waitFor(() => expect(sendResponse).toHaveBeenCalledWith(false)) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ + result: false, + error: "IPC error", + }), + ) }) it("AC-03: does not handle AddCommand when command is not a string", () => { @@ -167,7 +172,7 @@ describe("onMessageExternal - DeleteCommand", () => { ) }) - it("DC-02: calls sendResponse(false) when Ipc.callListener rejects", async () => { + 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() @@ -176,7 +181,12 @@ describe("onMessageExternal - DeleteCommand", () => { { origin: HUB_ORIGIN }, sendResponse, ) - await vi.waitFor(() => expect(sendResponse).toHaveBeenCalledWith(false)) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ + result: false, + error: "IPC error", + }), + ) }) it("DC-03: does not handle DeleteCommand when id is not a string", () => { @@ -277,13 +287,14 @@ describe("shareCommandToHub", () => { await vi.waitFor(() => expect(response).toHaveBeenCalledWith(false)) }) - it("SH-03: calls response(false) when chrome.tabs.create throws", async () => { + 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 () => { diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index e92c1051..a5c157e2 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -16,14 +16,16 @@ export const shareCommandToHub = ( // 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 { - const onPortConnect = (port: chrome.runtime.Port) => { + onPortConnect = (port: chrome.runtime.Port) => { if (port.name !== "hub-share") return if (port.sender?.tab?.id !== tabId) return - chrome.runtime.onConnectExternal.removeListener(onPortConnect) + chrome.runtime.onConnectExternal.removeListener(onPortConnect!) const cleanup = () => { clearInterval(timer) @@ -66,6 +68,9 @@ export const shareCommandToHub = ( response(true) } catch (err) { + if (onPortConnect) { + chrome.runtime.onConnectExternal.removeListener(onPortConnect) + } console.error("[ShareCommandToHub] Failed to open hub tab:", err) response(false) } @@ -92,7 +97,7 @@ function onMessageExternal( .then(sendResponse) .catch((err) => { console.error("[onMessageExternal] AddCommand failed:", err) - sendResponse(false) + sendResponse({ result: false, error: err?.message ?? "Unknown error" }) }) return true } @@ -102,7 +107,7 @@ function onMessageExternal( .then(sendResponse) .catch((err) => { console.error("[onMessageExternal] DeleteCommand failed:", err) - sendResponse(false) + sendResponse({ result: false, error: err?.message ?? "Unknown error" }) }) return true } From 54eafae235edf17f5767fd58caf7f482e4d00941 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 03:56:14 +0000 Subject: [PATCH 13/18] fix: address code review - remove non-null assertion and add error handling to addCommand Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/4e5a50d5-b06a-4da9-843f-82bba728b6dd Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/background_script.ts | 4 ++++ packages/extension/src/services/hub/background.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 9c9cde81..3150f49c 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -232,6 +232,10 @@ const commandFuncs = { const installId = await getOrCreateClientId() response({ result: true, install_id: installId }) }) + .catch((err) => { + console.error("[addCommand] Failed:", err) + response({ result: false, error: err?.message ?? "Unknown error" }) + }) return true }, diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index a5c157e2..90462752 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -21,11 +21,13 @@ export const shareCommandToHub = ( const share = async () => { try { - onPortConnect = (port: chrome.runtime.Port) => { + // Use a named function expression so the handler can remove itself without + // needing a non-null assertion on the outer `onPortConnect` variable. + onPortConnect = function portConnect(port: chrome.runtime.Port) { if (port.name !== "hub-share") return if (port.sender?.tab?.id !== tabId) return - chrome.runtime.onConnectExternal.removeListener(onPortConnect!) + chrome.runtime.onConnectExternal.removeListener(portConnect) const cleanup = () => { clearInterval(timer) From 4b39fef4ca9a24f09e3c5f936f4d9be811885ac4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 03:58:17 +0000 Subject: [PATCH 14/18] fix: rename install_id to client_id and clarify portConnect vs onPortConnect usage Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/4e5a50d5-b06a-4da9-843f-82bba728b6dd Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/background_script.ts | 4 ++-- packages/extension/src/services/hub/background.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 3150f49c..2b2d9c8a 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -229,8 +229,8 @@ const commandFuncs = { ) }) .then(async () => { - const installId = await getOrCreateClientId() - response({ result: true, install_id: installId }) + const clientId = await getOrCreateClientId() + response({ result: true, client_id: clientId }) }) .catch((err) => { console.error("[addCommand] Failed:", err) diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 90462752..335b5ee1 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -21,8 +21,10 @@ export const shareCommandToHub = ( const share = async () => { try { - // Use a named function expression so the handler can remove itself without - // needing a non-null assertion on the outer `onPortConnect` variable. + // 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 From bd6d42f6e30c0fd2d972191dce768c3a885d43e2 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sun, 10 May 2026 13:51:52 +0900 Subject: [PATCH 15/18] Fix: Property name. --- packages/extension/src/background_script.ts | 8 ++++++-- packages/extension/src/const.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 2b2d9c8a..6a61fc4c 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -28,7 +28,11 @@ 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, getOrCreateClientId } from "@/services/analytics" +import { + ANALYTICS_EVENTS, + sendEvent, + getOrCreateClientId, +} from "@/services/analytics" import * as HubBackground from "@/services/hub/background" import { importIf } from "@import-if" @@ -230,7 +234,7 @@ const commandFuncs = { }) .then(async () => { const clientId = await getOrCreateClientId() - response({ result: true, client_id: clientId }) + response({ result: true, install_id: clientId }) }) .catch((err) => { console.error("[addCommand] Failed:", err) diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index a2807734..6637d96a 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 ?? "http://localhost:3000" export const NEW_HUB_SUPPORTED_LOCALES = [ "de", From cefad1bfc5fa882d6f9eac09ff5e3576b040175d Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sun, 10 May 2026 14:14:34 +0900 Subject: [PATCH 16/18] Update: Remove localhost from `externally_connectable.matches`. --- packages/extension/src/const.ts | 2 +- packages/extension/vite.config.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index 6637d96a..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 ?? "http://localhost:3000" + import.meta.env?.VITE_NEW_HUB_URL ?? "https://selection-command.com" export const NEW_HUB_SUPPORTED_LOCALES = [ "de", 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")], From 9a92e95abfc30756e770f3552ad7b872a15368df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 05:20:09 +0000 Subject: [PATCH 17/18] fix: fix Ipc.callListener type, hubOrigin calculation, and hubShare error handling Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/495d22c6-86f7-43fb-9150-1e0944f960be Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/services/hub/background.ts | 15 ++++++++++----- packages/extension/src/services/hubShare.test.ts | 2 +- packages/extension/src/services/hubShare.ts | 4 +++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 335b5ee1..65c25372 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -7,6 +7,8 @@ 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, @@ -88,16 +90,16 @@ function onMessageExternal( sender: chrome.runtime.MessageSender, sendResponse: (response?: unknown) => void, ): boolean { - const hubOrigin = new URL(NEW_HUB_URL).origin 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 }, boolean>(BgCommand.addCommand, { - command, - }) + Ipc.callListener< + { command: string }, + { result: boolean; error?: string; client_id?: string } + >(BgCommand.addCommand, { command }) .then(sendResponse) .catch((err) => { console.error("[onMessageExternal] AddCommand failed:", err) @@ -107,7 +109,10 @@ function onMessageExternal( } if (action === "DeleteCommand" && typeof id === "string") { - Ipc.callListener<{ id: string }, boolean>(BgCommand.removeCommand, { id }) + Ipc.callListener<{ id: string }, { result: boolean; error?: string }>( + BgCommand.removeCommand, + { id }, + ) .then(sendResponse) .catch((err) => { console.error("[onMessageExternal] DeleteCommand failed:", err) diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts index a6a8186b..1f364578 100644 --- a/packages/extension/src/services/hubShare.test.ts +++ b/packages/extension/src/services/hubShare.test.ts @@ -228,7 +228,7 @@ describe("toSubmitCommandInput", () => { describe("shareCommandToHub", () => { beforeEach(() => { vi.spyOn(chrome.i18n, "getUILanguage").mockReturnValue("en") - vi.mocked(Ipc.send).mockClear() + vi.mocked(Ipc.send).mockResolvedValue(true) }) afterEach(() => { diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 5ce12f9b..636124a7 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -74,6 +74,8 @@ export function shareCommandToHub(command: SelectionCommand): boolean { return false } - Ipc.send(BgCommand.shareCommandToHub, input) + void Ipc.send(BgCommand.shareCommandToHub, input).catch((err) => { + console.error("[HubShare] Failed to share command:", err) + }) return true } From 9b3a15f758dee54a1b1f50d033ecdda3001d5e67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 05:22:13 +0000 Subject: [PATCH 18/18] test: add SH-08 error case test for shareCommandToHub catch handler Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/495d22c6-86f7-43fb-9150-1e0944f960be Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- .../extension/src/services/hubShare.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts index 1f364578..d395b585 100644 --- a/packages/extension/src/services/hubShare.test.ts +++ b/packages/extension/src/services/hubShare.test.ts @@ -273,5 +273,22 @@ describe("shareCommandToHub", () => { }), ) }) + + 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), + ) + }) })