From 8836f95c0bc94f880204fd282040ae5754c8511b Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 23 May 2026 14:47:32 +0900 Subject: [PATCH 1/6] Update: Enables to regenerate command Id if it duplicated on the hub. --- .../components/option/editor/CommandList.tsx | 31 ++++------------ .../extension/src/services/hub/background.ts | 35 +++++++++++++++++-- .../src/services/settings/settings.ts | 17 +++++++-- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index deacec62..e0f1125d 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from "react" -import { useFieldArray, useFormContext } from "react-hook-form" +import { useFieldArray } from "react-hook-form" import { DndContext, @@ -28,12 +28,7 @@ import { import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" import { SCREEN, COMMAND_TYPE, OPEN_MODE_TYPE_MAP } from "@/const" -import type { - Command, - CommandFolder, - SelectionCommand, - ShortcutCommand, -} from "@/types" +import type { Command, CommandFolder, SelectionCommand } from "@/types" // Imported services and hooks import { @@ -48,6 +43,7 @@ import { } from "@/services/option/commandUtils" import { isValidDrop } from "@/services/option/dragAndDrop" import { editCommandToHub } from "@/services/hubShare" +import { Settings } from "@/services/settings/settings" import { useCommandActions } from "@/hooks/option/useCommandActions" import { useCommandDragDrop } from "@/hooks/option/useCommandDragDrop" import { useSharedCommandIds } from "@/hooks/option/useSharedCommandIds" @@ -124,8 +120,6 @@ export const CommandList = ({ control }: CommandListProps) => { const sharedCommandIds = useSharedCommandIds() - const { getValues, setValue } = useFormContext() - const commandArray = useFieldArray({ name: "commands", control: control, @@ -306,22 +300,9 @@ export const CommandList = ({ control }: CommandListProps) => { } const handleUpdateCommandId = (commandId: string, newId: string) => { - // Update command ID - const idx = commandArray.fields.findIndex((f) => f.id === commandId) - if (idx >= 0) { - commandArray.update(idx, { ...commandArray.fields[idx], id: newId }) - } - // Update any shortcuts that reference this command ID - const currentShortcuts: ShortcutCommand[] = - getValues("shortcuts.shortcuts") ?? [] - if (currentShortcuts.some((s) => s.commandId === commandId)) { - setValue( - "shortcuts.shortcuts", - currentShortcuts.map((s) => - s.commandId === commandId ? { ...s, commandId: newId } : s, - ), - ) - } + Settings.updateCommandId(commandId, newId).catch((err) => { + console.error("[handleUpdateCommandId] Failed to update command ID:", err) + }) } const commandRemove = (idx: number) => { diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index c934a715..9830b173 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -18,6 +18,7 @@ import { } from "@/services/analytics" import { PopupOption } from "@/services/option/defaultSettings" import { + generateId, isSearchCommand, isPageActionCommand, isAiPromptCommand, @@ -78,6 +79,10 @@ export const shareCommandToHub = ( const share = async () => { try { + let currentParam = param + let idRegenerateCount = 0 + const MAX_ID_REGENERATE = 3 + // 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 @@ -94,9 +99,33 @@ export const shareCommandToHub = ( } const onMessage = (msg: unknown) => { - if ((msg as { type?: string })?.type === "share-command-ack") { - cleanup() + if ((msg as { type?: string })?.type !== "share-command-ack") return + + const ack = msg as { type: string; errorCode?: string } + + if ( + ack.errorCode === "DUPLICATE_COMMAND_ID" && + idRegenerateCount < MAX_ID_REGENERATE + ) { + idRegenerateCount++ + clearInterval(timer) + + const oldId = currentParam.id + const newId = generateId() + + Settings.updateCommandId(oldId, newId).catch((err) => { + console.error( + "[shareCommandToHub] Failed to update command ID:", + err, + ) + }) + + currentParam = { ...currentParam, id: newId } + port.postMessage({ type: "share-command", command: currentParam }) + return } + + cleanup() } port.onMessage.addListener(onMessage) @@ -110,7 +139,7 @@ export const shareCommandToHub = ( ) return } - port.postMessage({ type: "share-command", command: param }) + port.postMessage({ type: "share-command", command: currentParam }) }, RETRY_INTERVAL_MS) } // Register listener before tab creation so the Hub page can connect immediately on load diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts index 3d30e625..eb7afdc3 100644 --- a/packages/extension/src/services/settings/settings.ts +++ b/packages/extension/src/services/settings/settings.ts @@ -186,6 +186,20 @@ export const Settings = { return true }, + updateCommandId: async (oldId: string, newId: string): Promise => { + const commands = await Storage.getCommands() + await Storage.setCommands( + commands.map((c) => (c.id === oldId ? { ...c, id: newId } : c)), + ) + const shortcuts = await Storage.get(STORAGE_KEY.SHORTCUTS) + await Storage.set(STORAGE_KEY.SHORTCUTS, { + ...shortcuts, + shortcuts: (shortcuts?.shortcuts ?? []).map((s) => + s.commandId === oldId ? { ...s, commandId: newId } : s, + ), + }) + }, + reset: async () => { await Storage.set(STORAGE_KEY.USER, DefaultSettings) await Storage.setCommands(getDefaultCommands(getUILanguage())) @@ -305,8 +319,7 @@ const migrate0_11_5 = (data: SettingsType): SettingsType => { data.commands = data.commands.map((c) => { if (c.id.length === 36) return c c.id = - DefaultCommands.find((dc) => dc.title === c.title)?.id ?? - generateId() + DefaultCommands.find((dc) => dc.title === c.title)?.id ?? generateId() return c }) From 960ed02dbe227c4762d57b360c8178e932d3bdcf Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 23 May 2026 15:20:53 +0900 Subject: [PATCH 2/6] Fix: Url of the hub. --- packages/extension/package.json | 2 +- .../src/components/option/ShareButton.tsx | 29 ++++++++++++------- .../extension/src/services/hub/background.ts | 2 +- yarn.lock | 8 ++--- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index f5d31567..8e3fa722 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -48,7 +48,7 @@ "embla-carousel-react": "^8.6.0", "get-xpath": "^3.3.0", "lottie-web": "^5.12.2", - "lucide-react": "^0.483.0", + "lucide-react": "^1.16.0", "platform": "^1.3.6", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 54d201e7..6bfe3cd8 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -1,5 +1,5 @@ import { useState, useRef } from "react" -import { Share } from "lucide-react" +import { Share, CloudCheck } from "lucide-react" import { Tooltip } from "@/components/Tooltip" import { cn, isUUIDv7, generateId } from "@/lib/utils" import { t } from "@/services/i18n" @@ -39,7 +39,7 @@ export const ShareButton = ({ if (isShared) { // Open the hub dashboard page for the shared command const locale = getHubLocale() - const url = `${NEW_HUB_URL}/${locale}/dashboard/commands?id=${encodeURIComponent(command.id)}` + const url = `${NEW_HUB_URL}/${locale}/dashboard/mycommands?id=${encodeURIComponent(command.id)}` chrome.tabs.create({ url }) return } @@ -76,14 +76,23 @@ export const ShareButton = ({ onClick={handleClick} ref={buttonRef} > - + {!isShared ? ( + + ) : ( + + )} ((resolve) => chrome.tabs.create( { - url: `${NEW_HUB_URL}/${param.locale}/dashboard/commands?id=${encodeURIComponent(param.id)}`, + url: `${NEW_HUB_URL}/${param.locale}/dashboard/mycommands?id=${encodeURIComponent(param.id)}`, }, resolve, ), diff --git a/yarn.lock b/yarn.lock index 27d31c30..66695f3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5536,10 +5536,10 @@ lucide-react@^0.468.0: resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.468.0.tgz#830c1bfd905575ddd23b832baa420c87db166910" integrity sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA== -lucide-react@^0.483.0: - version "0.483.0" - resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.483.0.tgz#036c538173a8c310ac6b7d6af150543e5ecdf116" - integrity sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA== +lucide-react@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-1.16.0.tgz#8c19a757982ed28e28afd0da9a950d3152e4b7e5" + integrity sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ== lz-string@^1.5.0: version "1.5.0" From 2ef11e468ff2299ef1a68f97a1be8c54a3ae06c0 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 23 May 2026 16:59:17 +0900 Subject: [PATCH 3/6] Fix: Lint errors. --- .../src/components/option/editor/ShortcutList.tsx | 9 ++------- packages/hub/eslint.config.mjs | 8 +++++++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/extension/src/components/option/editor/ShortcutList.tsx b/packages/extension/src/components/option/editor/ShortcutList.tsx index 438c8623..2fef9bb0 100644 --- a/packages/extension/src/components/option/editor/ShortcutList.tsx +++ b/packages/extension/src/components/option/editor/ShortcutList.tsx @@ -1,10 +1,5 @@ import { useEffect, useState, useMemo } from "react" -import { - Control, - useFieldArray, - useWatch, - useFormContext, -} from "react-hook-form" +import { useFieldArray, useWatch, useFormContext } from "react-hook-form" import { Keyboard, SquareArrowOutUpRight } from "lucide-react" import { SelectField } from "@/components/option/field/SelectField" import type { SelectOptionType } from "@/components/option/field/SelectField" @@ -30,7 +25,7 @@ import { INSERT, toInsertTemplate } from "@/services/pageAction" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) type ShortcutListProps = { - control: Control + control: any } const isTextSelectionOnly = (command: Command) => { diff --git a/packages/hub/eslint.config.mjs b/packages/hub/eslint.config.mjs index 60e186d6..f39a3003 100644 --- a/packages/hub/eslint.config.mjs +++ b/packages/hub/eslint.config.mjs @@ -5,7 +5,13 @@ import tseslint from "typescript-eslint" export default tseslint.config( ...rootConfig, { - ignores: [".next/**", "out/**", "scripts/**", "*.config.*"], + ignores: [ + ".next/**", + "out/**", + "scripts/**", + "*.config.*", + "next-env.d.ts", + ], }, { files: ["**/*.{ts,tsx,js,jsx}"], From fb8658ff30197cbee0a0a4340a48a914b3c89bba Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 23 May 2026 18:43:37 +0900 Subject: [PATCH 4/6] Update: Update the share button display after a command submission is completed. --- .../src/hooks/option/useSharedCommandIds.ts | 9 +++ .../src/services/hub/background.test.ts | 57 ++++++++++++++++- .../extension/src/services/hub/background.ts | 64 +++++++++++++------ .../extension/src/services/storage/const.ts | 1 + 4 files changed, 107 insertions(+), 24 deletions(-) diff --git a/packages/extension/src/hooks/option/useSharedCommandIds.ts b/packages/extension/src/hooks/option/useSharedCommandIds.ts index 15a6e43e..c6725dbc 100644 --- a/packages/extension/src/hooks/option/useSharedCommandIds.ts +++ b/packages/extension/src/hooks/option/useSharedCommandIds.ts @@ -13,8 +13,10 @@ export function useSharedCommandIds(): Set { useEffect(() => { let cancelled = false + let currentHubUser: HubUser | null = null const fetchIds = async (hubUser: HubUser | null) => { + currentHubUser = hubUser if (cancelled) return if (!hubUser) { setSharedIds(new Set()) @@ -35,9 +37,16 @@ export function useSharedCommandIds(): Set { (newVal) => fetchIds(newVal), ) + // Re-fetch after a command is successfully shared to the hub + const unsubscribeSharedAt = Storage.addListener( + LOCAL_STORAGE_KEY.HUB_SHARED_AT, + () => fetchIds(currentHubUser), + ) + return () => { cancelled = true unsubscribe() + unsubscribeSharedAt() } }, []) diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index a2367bc3..39eef424 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -23,7 +23,7 @@ vi.mock("@/services/storage", () => ({ set: vi.fn(), updateCommands: vi.fn(), }, - LOCAL_STORAGE_KEY: { HUB_USER: "hubUser" }, + LOCAL_STORAGE_KEY: { HUB_USER: "hubUser", HUB_SHARED_AT: "hubSharedAt" }, })) vi.mock("@/services/settings/settings", () => ({ @@ -1003,7 +1003,7 @@ describe("shareCommandToHub", () => { expect(mockPort.postMessage).not.toHaveBeenCalled() }) - it("SH-07: valid port sends share-command and removes listener on ack", async () => { + it("SH-07: valid port sends share-command; ack stops timer but keeps listener", async () => { vi.useFakeTimers() vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { cb?.({ id: 42 } as chrome.tabs.Tab) @@ -1040,8 +1040,59 @@ describe("shareCommandToHub", () => { }) expect(chrome.runtime.onConnectExternal.removeListener).toHaveBeenCalled() + // ack stops the retry timer but keeps the message listener alive capturedOnMessage?.({ type: "share-command-ack" }) + expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + + it("SH-08: share-command-submitted updates HUB_SHARED_AT and removes listener", 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) + + vi.advanceTimersByTime(100) + + capturedOnMessage?.({ type: "share-command-ack" }) + expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled() + expect(Storage.set).not.toHaveBeenCalledWith( + LOCAL_STORAGE_KEY.HUB_SHARED_AT, + expect.any(Number), + ) + + capturedOnMessage?.({ type: "share-command-submitted", commandId: "cmd-1" }) expect(mockPort.onMessage.removeListener).toHaveBeenCalled() + await vi.waitFor(() => + expect(Storage.set).toHaveBeenCalledWith( + LOCAL_STORAGE_KEY.HUB_SHARED_AT, + expect.any(Number), + ), + ) vi.useRealTimers() }) @@ -1080,7 +1131,7 @@ describe("pushEditToHub", () => { expect(chrome.tabs.create).toHaveBeenCalledWith( { url: expect.stringContaining( - "hub.example.com/en/dashboard/commands?id=cmd-1", + "hub.example.com/en/dashboard/mycommands?id=cmd-1", ), }, expect.any(Function), diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 778373d5..308342cd 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -99,33 +99,55 @@ export const shareCommandToHub = ( } const onMessage = (msg: unknown) => { - if ((msg as { type?: string })?.type !== "share-command-ack") return - - const ack = msg as { type: string; errorCode?: string } + const type = (msg as { type?: string })?.type + + if (type === "share-command-ack") { + const ack = msg as { type: string; errorCode?: string } + + if ( + ack.errorCode === "DUPLICATE_COMMAND_ID" && + idRegenerateCount < MAX_ID_REGENERATE + ) { + idRegenerateCount++ + clearInterval(timer) + + const oldId = currentParam.id + const newId = generateId() + + Settings.updateCommandId(oldId, newId).catch((err) => { + console.error( + "[shareCommandToHub] Failed to update command ID:", + err, + ) + }) + + currentParam = { ...currentParam, id: newId } + port.postMessage({ type: "share-command", command: currentParam }) + return + } - if ( - ack.errorCode === "DUPLICATE_COMMAND_ID" && - idRegenerateCount < MAX_ID_REGENERATE - ) { - idRegenerateCount++ + // Stop retry timer now that ack is received clearInterval(timer) - const oldId = currentParam.id - const newId = generateId() - - Settings.updateCommandId(oldId, newId).catch((err) => { - console.error( - "[shareCommandToHub] Failed to update command ID:", - err, - ) - }) - - currentParam = { ...currentParam, id: newId } - port.postMessage({ type: "share-command", command: currentParam }) + if (ack.errorCode) { + // Error: stop listening + port.onMessage.removeListener(onMessage) + } + // Success: keep listener to await share-command-submitted return } - cleanup() + if (type === "share-command-submitted") { + port.onMessage.removeListener(onMessage) + Storage.set(LOCAL_STORAGE_KEY.HUB_SHARED_AT, Date.now()).catch( + (err) => { + console.error( + "[shareCommandToHub] Failed to update hubSharedAt:", + err, + ) + }, + ) + } } port.onMessage.addListener(onMessage) diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index 767326e2..e449e871 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -17,6 +17,7 @@ export enum LOCAL_STORAGE_KEY { DAILY_COMMANDS_BACKUP = "dailyCommandsBackup", WEEKLY_COMMANDS_BACKUP = "weeklyCommandsBackup", HUB_USER = "hubUser", + HUB_SHARED_AT = "hubSharedAt", } export enum SESSION_STORAGE_KEY { From 3904c46f9c3be825dab6646177f6e23965bb11d3 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 23 May 2026 18:46:11 +0900 Subject: [PATCH 5/6] Update: Adjust ui-design of shared-button. --- packages/extension/src/components/option/ShareButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 6bfe3cd8..d9da856d 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -71,7 +71,6 @@ export const ShareButton = ({ className={cn( "outline-gray-200 p-2 rounded-md transition hover:bg-green-100 hover:scale-125 group/share-btn", "disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none", - isShared && "bg-green-50", )} onClick={handleClick} ref={buttonRef} @@ -87,7 +86,7 @@ export const ShareButton = ({ ) : ( Date: Sat, 23 May 2026 19:07:31 +0900 Subject: [PATCH 6/6] Fix: Await updateCommandId before re-sending on DUPLICATE_COMMAND_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ローカルIDの更新完了前にHubへ再送信していた非同期の不整合を修正。 Settings.updateCommandId が失敗した場合はリスナーを削除して処理を中止し、 ローカルストレージとHub間のID不整合を防ぐ。 Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/hub/background.test.ts | 98 ++++++++++++++++++- .../extension/src/services/hub/background.ts | 10 +- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index 39eef424..be8e6da1 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -27,7 +27,7 @@ vi.mock("@/services/storage", () => ({ })) vi.mock("@/services/settings/settings", () => ({ - Settings: { addCommands: vi.fn() }, + Settings: { addCommands: vi.fn(), updateCommandId: vi.fn() }, })) vi.mock("@/services/analytics", () => ({ @@ -120,6 +120,7 @@ beforeEach(() => { resetEditSession() vi.mocked(Storage.updateCommands).mockResolvedValue(true) vi.mocked(Settings.addCommands).mockResolvedValue(true) + vi.mocked(Settings.updateCommandId).mockResolvedValue(undefined) vi.mocked(Storage.setCommands).mockResolvedValue(true) vi.mocked(Storage.set).mockResolvedValue(true) vi.mocked(sendEvent).mockResolvedValue(undefined as any) @@ -1096,6 +1097,101 @@ describe("shareCommandToHub", () => { vi.useRealTimers() }) + + it("SH-09: DUPLICATE_COMMAND_ID awaits updateCommandId before re-sending", 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) => Promise) | 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) + vi.advanceTimersByTime(100) + + // Simulate DUPLICATE_COMMAND_ID ack + await capturedOnMessage?.({ + type: "share-command-ack", + errorCode: "DUPLICATE_COMMAND_ID", + }) + + expect(Settings.updateCommandId).toHaveBeenCalledTimes(1) + // postMessage for retry must happen after updateCommandId resolves + expect(mockPort.postMessage).toHaveBeenLastCalledWith({ + type: "share-command", + command: expect.objectContaining({ + id: expect.not.stringMatching(param.id), + }), + }) + expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + + it("SH-10: DUPLICATE_COMMAND_ID stops share if updateCommandId fails", 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) + }) + vi.mocked(Settings.updateCommandId).mockRejectedValue( + new Error("update failed"), + ) + 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) => Promise) | 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) + vi.advanceTimersByTime(100) + + // Simulate DUPLICATE_COMMAND_ID ack with updateCommandId failure + await capturedOnMessage?.({ + type: "share-command-ack", + errorCode: "DUPLICATE_COMMAND_ID", + }) + + // Must not send new command since local storage update failed + expect(mockPort.postMessage).toHaveBeenCalledTimes(1) // only the initial retry send + 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 index 308342cd..0b8c5885 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -98,7 +98,7 @@ export const shareCommandToHub = ( port.onMessage.removeListener(onMessage) } - const onMessage = (msg: unknown) => { + const onMessage = async (msg: unknown) => { const type = (msg as { type?: string })?.type if (type === "share-command-ack") { @@ -114,12 +114,16 @@ export const shareCommandToHub = ( const oldId = currentParam.id const newId = generateId() - Settings.updateCommandId(oldId, newId).catch((err) => { + try { + await Settings.updateCommandId(oldId, newId) + } catch (err) { console.error( "[shareCommandToHub] Failed to update command ID:", err, ) - }) + port.onMessage.removeListener(onMessage) + return + } currentParam = { ...currentParam, id: newId } port.postMessage({ type: "share-command", command: currentParam })