diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1d57c68..4a2e61a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: e2e: runs-on: ubuntu-latest container: - image: mcr.microsoft.com/playwright:v1.59.1-jammy + image: mcr.microsoft.com/playwright:v1.62.1-jammy steps: - name: Checkout code diff --git a/package.json b/package.json index aa40747b..5e7edbe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selection-command-monorepo", - "version": "1.1.0", + "version": "1.1.1", "private": true, "description": "Selection Command - Monorepo for Chrome Extension and Hub", "author": "ujiro99", diff --git a/packages/extension/e2e/generated-command-urls.ts b/packages/extension/e2e/generated-command-urls.ts index 497f461f..32703907 100644 --- a/packages/extension/e2e/generated-command-urls.ts +++ b/packages/extension/e2e/generated-command-urls.ts @@ -33,7 +33,6 @@ export const COMMAND_URLS: UrlEntry[] = [ { title: "AliExpress", locale: "es", searchUrl: "https://es.aliexpress.com/w/wholesale-%s.html" }, { title: "Buscar comandos en Hub", locale: "es", searchUrl: "https://selection-command.com/es?q=%pageUrl" }, { title: "Pesquisar comandos no Hub", locale: "pt-br", searchUrl: "https://selection-command.com/pt-BR?q=%pageUrl" }, - { title: "OLX", locale: "pt", searchUrl: "https://www.olx.pt/ads/q-%s/" }, { title: "Pesquisar comandos no Hub", locale: "pt", searchUrl: "https://selection-command.com/pt-PT?q=%pageUrl" }, { title: "Hub पर कमांड खोजें", locale: "hi", searchUrl: "https://selection-command.com/hi?q=%pageUrl" }, { title: "Tokopedia", locale: "id", searchUrl: "https://www.tokopedia.com/search?st=product&q=%s" }, diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 000b2c96..47fb9869 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -2,20 +2,15 @@ "manifest_version": 3, "name": "__MSG_extName__", "description": "__MSG_extDescription__", - "version": "1.1.0", + "version": "1.1.1", "default_locale": "en", "icons": { "128": "icon128.png" }, "content_scripts": [ { - "matches": [ - "http://*/*", - "https://*/*" - ], - "js": [ - "src/content_script.tsx" - ] + "matches": ["http://*/*", "https://*/*"], + "js": ["src/content_script.tsx"] }, { "matches": [ @@ -23,20 +18,14 @@ "https://selection-command-hub.siro-cola.workers.dev/*", "http://localhost:3000/*" ], - "js": [ - "src/new_command_hub.tsx" - ] + "js": ["src/new_command_hub.tsx"] }, { - "matches": [ - "https://*.siro-cola.workers.dev/*" - ], + "matches": ["https://*.siro-cola.workers.dev/*"], "include_globs": [ "https://*-selection-command-hub.siro-cola.workers.dev/*" ], - "js": [ - "src/new_command_hub.tsx" - ] + "js": ["src/new_command_hub.tsx"] } ], "background": { @@ -60,21 +49,11 @@ "system.display", "sidePanel" ], - "host_permissions": [ - "http://*/*", - "https://*/*" - ], + "host_permissions": ["http://*/*", "https://*/*"], "web_accessible_resources": [ { - "matches": [ - "http://*/*", - "https://*/*" - ], - "resources": [ - "assets/*.css", - "images/*", - "icon128.png" - ] + "matches": ["http://*/*", "https://*/*"], + "resources": ["assets/*.css", "images/*", "icon128.png"] } ], "commands": { diff --git a/packages/extension/package.json b/packages/extension/package.json index 3da12c78..08c5b87c 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@selection-command/extension", - "version": "1.1.0", + "version": "1.1.1", "private": true, "author": "ujiro99", "license": "MIT", diff --git a/packages/extension/public/SelectionCommandHub.png b/packages/extension/public/SelectionCommandHub.png index e623cd3d..18a96041 100644 Binary files a/packages/extension/public/SelectionCommandHub.png and b/packages/extension/public/SelectionCommandHub.png differ diff --git a/packages/extension/public/SelectionCommandHub_new.png b/packages/extension/public/SelectionCommandHub_new.png new file mode 100644 index 00000000..005227e2 Binary files /dev/null and b/packages/extension/public/SelectionCommandHub_new.png differ diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 633fc4af..91a1d709 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Bitte wählen Sie den Typ des Befehls, den Sie erstellen möchten" }, + "Option_commandType_hubLink": { + "message": "Befehle finden" + }, "Option_commandType_search_title": { "message": "Suchen" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Bewertung schreiben" }, + "hub_share_toast_title": { + "message": "Befehl erstellt!" + }, + "hub_share_toast_message": { + "message": "Möchten Sie ihn mit dem Selection Command Hub teilen?" + }, + "hub_share_toast_button": { + "message": "Teilen" + }, + "hub_share_toast_later": { + "message": "Später" + }, "Option_shortcuts": { "message": "Tastenkombinationen" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 143cc3da..8514e366 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -359,6 +359,9 @@ "Option_commandType_description": { "message": "Please select the type of command you want to create" }, + "Option_commandType_hubLink": { + "message": "Find Commands" + }, "Option_commandType_search_title": { "message": "Search" }, @@ -1013,6 +1016,21 @@ "review_request_button": { "message": "Write a Review" }, + "hub_share_toast_title": { + "message": "Command created!", + "description": "Toast title shown once, right after a new command is created." + }, + "hub_share_toast_message": { + "message": "Want to share it with the Selection Command Hub?", + "description": "Toast message inviting the user to share the newly created command to the Hub." + }, + "hub_share_toast_button": { + "message": "Share", + "description": "Button label to share the newly created command to the Hub from the toast." + }, + "hub_share_toast_later": { + "message": "Later" + }, "Option_shortcuts": { "message": "Shortcut Keys" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index f18df98f..64e8143a 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Por favor seleccione el tipo de comando que desea crear" }, + "Option_commandType_hubLink": { + "message": "Buscar Comandos" + }, "Option_commandType_search_title": { "message": "Búsqueda" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Escribir una Reseña" }, + "hub_share_toast_title": { + "message": "¡Comando creado!" + }, + "hub_share_toast_message": { + "message": "¿Quieres compartirlo con Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Compartir" + }, + "hub_share_toast_later": { + "message": "Luego" + }, "Option_shortcuts": { "message": "Teclas de Acceso Rápido" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 1513d3f3..3f91714e 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Veuillez sélectionner le type de commande que vous souhaitez créer" }, + "Option_commandType_hubLink": { + "message": "Rechercher des Commandes" + }, "Option_commandType_search_title": { "message": "Recherche" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Écrire un Avis" }, + "hub_share_toast_title": { + "message": "Commande créée !" + }, + "hub_share_toast_message": { + "message": "Souhaitez-vous la partager avec Selection Command Hub ?" + }, + "hub_share_toast_button": { + "message": "Partager" + }, + "hub_share_toast_later": { + "message": "Plus tard" + }, "Option_shortcuts": { "message": "Raccourcis Clavier" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 9e76ed86..21d441da 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "कृपया उस कमांड का प्रकार चुनें जिसे आप बनाना चाहते हैं" }, + "Option_commandType_hubLink": { + "message": "कमांड खोजें" + }, "Option_commandType_search_title": { "message": "खोज" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "समीक्षा लिखें" }, + "hub_share_toast_title": { + "message": "कमांड बन गया है!" + }, + "hub_share_toast_message": { + "message": "क्या आप इसे Selection Command Hub पर साझा करना चाहेंगे?" + }, + "hub_share_toast_button": { + "message": "साझा करें" + }, + "hub_share_toast_later": { + "message": "बाद में" + }, "Option_shortcuts": { "message": "शॉर्टकट" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 2d645b1c..a34ea6d4 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Silakan pilih jenis perintah yang ingin Anda buat" }, + "Option_commandType_hubLink": { + "message": "Cari Perintah" + }, "Option_commandType_search_title": { "message": "Pencarian" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_title": { + "message": "Perintah berhasil dibuat!" + }, + "hub_share_toast_message": { + "message": "Ingin membagikannya ke Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Bagikan" + }, + "hub_share_toast_later": { + "message": "Nanti" + }, "Option_shortcuts": { "message": "Tombol Pintas" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 7783179f..ce36201e 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Seleziona il tipo di comando che desideri creare" }, + "Option_commandType_hubLink": { + "message": "Cerca Comandi" + }, "Option_commandType_search_title": { "message": "Ricerca" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Scrivi recensione" }, + "hub_share_toast_title": { + "message": "Comando creato!" + }, + "hub_share_toast_message": { + "message": "Vuoi condividerlo con Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Condividi" + }, + "hub_share_toast_later": { + "message": "Dopo" + }, "Option_shortcuts": { "message": "Scorciatoie" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index b7575757..453c3517 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "作成するコマンドの種類を選択してください" }, + "Option_commandType_hubLink": { + "message": "コマンドを探す" + }, "Option_commandType_search_title": { "message": "検索" }, @@ -999,6 +1002,18 @@ "review_request_button": { "message": "レビューする" }, + "hub_share_toast_title": { + "message": "コマンドが作成されました!" + }, + "hub_share_toast_message": { + "message": "Selection Command Hubへ共有してみませんか?" + }, + "hub_share_toast_button": { + "message": "共有する" + }, + "hub_share_toast_later": { + "message": "後で" + }, "Option_shortcuts": { "message": "ショートカットキー" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index bd50bf0f..c6f89e08 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "생성하려는 명령 유형을 선택하세요" }, + "Option_commandType_hubLink": { + "message": "명령 찾기" + }, "Option_commandType_search_title": { "message": "검색" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "리뷰 작성" }, + "hub_share_toast_title": { + "message": "명령이 생성되었습니다!" + }, + "hub_share_toast_message": { + "message": "Selection Command Hub에 공유하시겠습니까?" + }, + "hub_share_toast_button": { + "message": "공유하기" + }, + "hub_share_toast_later": { + "message": "나중에" + }, "Option_shortcuts": { "message": "단축키" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index ee087b83..0bb6f011 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Sila pilih jenis arahan yang ingin anda cipta" }, + "Option_commandType_hubLink": { + "message": "Cari Arahan" + }, "Option_commandType_search_title": { "message": "Carian" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Tulis Ulasan" }, + "hub_share_toast_title": { + "message": "Arahan berjaya dicipta!" + }, + "hub_share_toast_message": { + "message": "Adakah anda ingin berkongsinya ke Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Kongsi" + }, + "hub_share_toast_later": { + "message": "Kemudian" + }, "Option_shortcuts": { "message": "Kekunci Pintas" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index d7b024e1..5e865323 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Por favor, selecione o tipo de comando que deseja criar" }, + "Option_commandType_hubLink": { + "message": "Buscar Comandos" + }, "Option_commandType_search_title": { "message": "Pesquisa" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_title": { + "message": "Comando criado!" + }, + "hub_share_toast_message": { + "message": "Deseja compartilhá-lo com o Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Compartilhar" + }, + "hub_share_toast_later": { + "message": "Mais tarde" + }, "Option_shortcuts": { "message": "Teclas de Atalho" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 142d2cc3..c57367cc 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Por favor, selecione o tipo de comando que deseja criar" }, + "Option_commandType_hubLink": { + "message": "Procurar Comandos" + }, "Option_commandType_search_title": { "message": "Pesquisa" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Escrever uma Avaliação" }, + "hub_share_toast_title": { + "message": "Comando criado!" + }, + "hub_share_toast_message": { + "message": "Deseja partilhá-lo com o Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Partilhar" + }, + "hub_share_toast_later": { + "message": "Mais tarde" + }, "Option_shortcuts": { "message": "Teclas de Atalho" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index a8d06f10..6c25f653 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "Пожалуйста, выберите тип команды, которую вы хотите создать" }, + "Option_commandType_hubLink": { + "message": "Найти команды" + }, "Option_commandType_search_title": { "message": "Поиск" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "Написать отзыв" }, + "hub_share_toast_title": { + "message": "Команда создана!" + }, + "hub_share_toast_message": { + "message": "Хотите поделиться ею в Selection Command Hub?" + }, + "hub_share_toast_button": { + "message": "Поделиться" + }, + "hub_share_toast_later": { + "message": "Позже" + }, "Option_shortcuts": { "message": "Горячие клавиши" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 8b83224b..f9006916 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -356,6 +356,9 @@ "Option_commandType_description": { "message": "请选择您要创建的命令类型" }, + "Option_commandType_hubLink": { + "message": "查找命令" + }, "Option_commandType_search_title": { "message": "搜索" }, @@ -1002,6 +1005,18 @@ "review_request_button": { "message": "写评论" }, + "hub_share_toast_title": { + "message": "命令已创建!" + }, + "hub_share_toast_message": { + "message": "要将它分享到 Selection Command Hub 吗?" + }, + "hub_share_toast_button": { + "message": "分享" + }, + "hub_share_toast_later": { + "message": "稍后" + }, "Option_shortcuts": { "message": "快捷键" }, diff --git a/packages/extension/public/party_popper.png b/packages/extension/public/party_popper.png new file mode 100644 index 00000000..0e62d90c Binary files /dev/null and b/packages/extension/public/party_popper.png differ diff --git a/packages/extension/scripts/e2e-ignore-urls.txt b/packages/extension/scripts/e2e-ignore-urls.txt index 54f871f4..132a482e 100644 --- a/packages/extension/scripts/e2e-ignore-urls.txt +++ b/packages/extension/scripts/e2e-ignore-urls.txt @@ -14,3 +14,4 @@ https://www.zhihu.com/search?q=%s https://www.ebay.de/sch/i.html?_nkw=%s https://www.ebay.es/sch/i.html?_nkw=%s https://www.ebay.it/sch/i.html?_nkw=%s +https://www.olx.pt/ads/q-%s/ diff --git a/packages/extension/src/action/aiPrompt.test.ts b/packages/extension/src/action/aiPrompt.test.ts index 7d57f7ce..9982ad13 100644 --- a/packages/extension/src/action/aiPrompt.test.ts +++ b/packages/extension/src/action/aiPrompt.test.ts @@ -59,6 +59,7 @@ const makeDomService = (overrides?: Partial): AiService => ({ faviconUrl: "", inputSelectors: [".ql-editor"], submitSelectors: ["button.send"], + copySelectors: [], selectorType: "css" as any, ...overrides, }) @@ -70,6 +71,7 @@ const makeQueryService = (overrides?: Partial): AiService => ({ faviconUrl: "", inputSelectors: ["#prompt-textarea"], submitSelectors: ["button#submit"], + copySelectors: [], selectorType: "css" as any, queryUrl: "https://chatgpt.com/?prompt=%s", autoSubmit: false, @@ -83,6 +85,7 @@ const makeAutoSubmitService = (overrides?: Partial): AiService => ({ faviconUrl: "", inputSelectors: ["div#ask-input"], submitSelectors: ["button[aria-label='Submit']"], + copySelectors: [], selectorType: "css" as any, queryUrl: "https://www.perplexity.ai/search/new?q=%s", autoSubmit: true, diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts index af0a762f..1dc0f846 100644 --- a/packages/extension/src/action/aiPrompt.ts +++ b/packages/extension/src/action/aiPrompt.ts @@ -17,7 +17,13 @@ import { SelectorType, } from "@/const" import { PopupOption } from "@/services/option/defaultSettings" -import type { ExecuteCommandParams, PageActionStep, UrlParam } from "@/types" +import type { + ExecuteCommandParams, + PageActionStep, + UrlParam, + AiService, + AiPromptOption, +} from "@/types" import type { OpenAndRunProps } from "@/services/pageAction/background" import type { OpenSidePanelProps } from "@/services/chrome" import { findAiService } from "@/services/aiPrompt" @@ -62,6 +68,390 @@ const toPageActionMode = (mode: OPEN_MODE): PAGE_ACTION_OPEN_MODE => { } } +// Builds a PageActionStep, filling in the id/skipRenderWait boilerplate that +// every step shares. +const createStep = ( + param: PageActionStep["param"], + opts: { delayMs?: number; skipRenderWait?: boolean } = {}, +): PageActionStep => ({ + id: generateRandomID(), + delayMs: opts.delayMs ?? 0, + skipRenderWait: opts.skipRenderWait ?? false, + param, +}) + +const createStartStep = (): PageActionStep => + createStep({ + type: PAGE_ACTION_CONTROL.start, + label: "Start", + mode: "aiPrompt", + }) + +const createEndStep = (): PageActionStep => + createStep({ type: PAGE_ACTION_CONTROL.end, label: "End" }) + +type PromptRequirements = { + needClipboard: boolean + needPageHtml: boolean + pageHtml: string | undefined + needSelectionHtml: boolean + selectionHtml: string | undefined + needFilePaste: boolean + useQueryUrl: boolean +} + +// Determines which optional inputs (clipboard, page/selection HTML) the +// prompt needs, and whether the query-URL approach can be used at all. +const analyzePromptRequirements = ( + aiPromptOption: AiPromptOption, + service: AiService, +): PromptRequirements => { + // Checks if any step requires clipboard data + const needClipboard = aiPromptOption.prompt.includes( + toInsertTemplate(INSERT.CLIPBOARD), + ) + + // Detect HTML placeholders that require file-paste upload instead of text embedding. + // File paste cannot be used with queryUrl mode (URL length limit), so these force + // the DOM input path. + const needPageHtml = aiPromptOption.prompt.includes( + toInsertTemplate(INSERT.PAGE_HTML), + ) + const pageHtml = needPageHtml ? getPageHtml() : undefined + + // Only one HTML attachment is allowed per execution; PAGE_HTML takes + // priority over SELECTION_HTML when the prompt contains both. + const needSelectionHtml = + !needPageHtml && + aiPromptOption.prompt.includes(toInsertTemplate(INSERT.SELECTION_HTML)) + const selectionHtml = needSelectionHtml ? getSelectionHtml() : undefined + + const needFilePaste = needPageHtml || needSelectionHtml + + // Use URL query input when the service supports it and neither clipboard nor + // file-paste content is needed. Both clipboard and HTML content require the DOM + // input path since they can't be embedded in a URL safely. + const useQueryUrl = + isValidString(service.queryUrl) && !needClipboard && !needFilePaste + + return { + needClipboard, + needPageHtml, + pageHtml, + needSelectionHtml, + selectionHtml, + needFilePaste, + useQueryUrl, + } +} + +// Builds the (optional) Submit step for the query-URL approach. When +// autoSubmit is true a fallback click is still added in case the service +// fails to auto-process the prompt after navigation. +const buildQueryUrlSubmitStep = ( + service: AiService, + submitSelector: string, + inputSelector: string, +): PageActionStep[] => { + if (!service.autoSubmit && submitSelector.length === 0) { + console.warn( + `[AiPrompt] queryUrl mode: submitSelectors is empty for "${service.id}" but autoSubmit is false. Submit step will be skipped.`, + ) + } + + // When autoSubmit is true (e.g. ChatGPT, Perplexity) the service is + // expected to process the prompt automatically after navigation. But + // this can silently fail to fire (e.g. while ChatGPT is re-validating + // the session right after opening the URL), leaving the prompt sitting + // unsent in the input. As a fallback, add a Submit click step that only + // fires if the input still contains the unsent prompt. + if ( + service.autoSubmit && + submitSelector.length > 0 && + inputSelector.length > 0 + ) { + console.debug( + `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}". Adding fallback Submit step.`, + ) + return [ + createStep( + { + type: PAGE_ACTION_EVENT.click, + label: "Submit (fallback)", + selector: submitSelector, + selectorType: SelectorType.css, + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.skip, + conditionType: PAGE_ACTION_CONDITION_TYPE.empty, + selector: inputSelector, + selectorType: SelectorType.css, + }, + }, + { delayMs: 1000 }, + ), + ] + } + + if (!service.autoSubmit && submitSelector.length > 0) { + return [ + createStep( + { + type: PAGE_ACTION_EVENT.click, + label: "Submit", + selector: submitSelector, + selectorType: SelectorType.css, + }, + { delayMs: 200 }, + ), + ] + } + + if (service.autoSubmit) { + console.warn( + `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}" but submitSelectors or inputSelectors is empty. Fallback Submit step will be skipped.`, + ) + } + return [] +} + +// Query URL approach: pre-expand the prompt and navigate to the service's query URL. +const buildQueryUrlSteps = ( + service: AiService, + aiPromptOption: AiPromptOption, + selectionText: string, + pageUrl: string | undefined, +): { steps: PageActionStep[]; urlParam: UrlParam; serviceUrl: string } => { + // Pre-expand the prompt template with synchronously available variables. + // INSERT.CLIPBOARD is intentionally excluded here: clipboard text is not + // available in the content script context and must be read asynchronously + // in the background. When the prompt contains {{Clipboard}}, useQueryUrl + // is false and the DOM input approach is used instead. + const expandedPrompt = safeInterpolate(aiPromptOption.prompt, { + [InsertSymbol[INSERT.SELECTED_TEXT]]: selectionText, + [InsertSymbol[INSERT.URL]]: pageUrl ?? "", + [InsertSymbol[INSERT.LANG]]: getUILanguage(), + }) + + const finalPrompt = service.urlToMarkdown + ? convertUrlsToMarkdown(expandedPrompt) + : expandedPrompt + + const urlParam: UrlParam = { + searchUrl: service.queryUrl!, + selectionText: finalPrompt, + useClipboard: false, + } + // Resolve the final URL for cases that require a plain string (e.g. side panel). + const serviceUrl = toUrl(urlParam) as string + + const submitSelector = service.submitSelectors.join(", ") + const inputSelector = service.inputSelectors.join(", ") + const submitStep = buildQueryUrlSubmitStep( + service, + submitSelector, + inputSelector, + ) + + const steps: PageActionStep[] = [ + createStartStep(), + ...submitStep, + createEndStep(), + ] + + return { steps, urlParam, serviceUrl } +} + +// When HTML placeholders are present, build filePaste steps that upload the +// HTML as a text file attachment before typing the prompt. +const buildFilePasteSteps = ( + inputSelector: string, + needPageHtml: boolean, + needSelectionHtml: boolean, +): PageActionStep[] => { + const pageTitle = + document.title + .replace(/[<>:"/\\|?*]/g, "") + .replace(/\p{Cc}/gu, "") + .trim() + .replace(/\s+/g, "-") + .slice(0, 64) || "page" + + const filePasteSteps: PageActionStep[] = [] + if (needPageHtml) { + filePasteSteps.push( + createStep( + { + type: PAGE_ACTION_EVENT.filePaste, + label: "Paste page HTML", + selector: inputSelector, + selectorType: SelectorType.css, + value: toInsertTemplate(INSERT.PAGE_HTML), + fileName: `${pageTitle}.html`, + fileType: "text/html", + }, + { delayMs: 200 }, + ), + ) + } + if (needSelectionHtml) { + filePasteSteps.push( + createStep( + { + type: PAGE_ACTION_EVENT.filePaste, + label: "Paste selection HTML", + selector: inputSelector, + selectorType: SelectorType.css, + value: toInsertTemplate(INSERT.SELECTION_HTML), + fileName: `${pageTitle}.html`, + fileType: "text/html", + }, + { delayMs: 200 }, + ), + ) + } + return filePasteSteps +} + +// DOM input approach: type the prompt into the service's input element. +const buildDomInputSteps = ( + service: AiService, + aiPromptOption: AiPromptOption, + selectionText: string, + needClipboard: boolean, + useClipboard: boolean | undefined, + needPageHtml: boolean, + needSelectionHtml: boolean, + needFilePaste: boolean, +): { steps: PageActionStep[]; urlParam: UrlParam; serviceUrl: string } => { + const inputSelector = service.inputSelectors.join(", ") + const submitSelector = service.submitSelectors.join(", ") + + const serviceUrl = service.url + const urlParam: UrlParam = { + searchUrl: service.url, + selectionText, + useClipboard: needClipboard || (useClipboard ?? false), + } + + // When HTML placeholders are present, build filePaste steps that upload + // the HTML as a text file attachment before typing the prompt. + // The placeholders are stripped from the input step value so the AI + // receives the file as an attachment and the remaining text as the prompt. + const filePasteSteps = buildFilePasteSteps( + inputSelector, + needPageHtml, + needSelectionHtml, + ) + + // Remove the HTML placeholders from the prompt value. + const promptValue = needFilePaste + ? aiPromptOption.prompt + .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "") + .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "") + .trim() + : aiPromptOption.prompt + + const steps: PageActionStep[] = [ + createStartStep(), + ...filePasteSteps, + createStep( + { + type: PAGE_ACTION_EVENT.input, + label: "Input prompt", + selector: inputSelector, + selectorType: SelectorType.css, + value: promptValue, + }, + { delayMs: 200 }, + ), + createStep( + { + type: PAGE_ACTION_EVENT.click, + label: "Submit", + selector: submitSelector, + selectorType: SelectorType.css, + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil, + conditionType: PAGE_ACTION_CONDITION_TYPE.clickable, + selector: submitSelector, + selectorType: SelectorType.css, + timeout: PAGE_ACTION_TIMEOUT * 2, // Allow more time for click + }, + }, + { delayMs: 200 }, + ), + createEndStep(), + ] + + return { steps, urlParam, serviceUrl } +} + +// Handles side panel mode: stores pending steps in session storage, then opens +// the side panel. The background onConnect handler will pick up the pending +// steps when the side panel content script establishes a port connection. +// Clipboard reading is deferred to the background script context to avoid +// browser security restrictions on navigator.clipboard in content scripts. +const runSidePanelAction = async (params: { + serviceUrl: string + steps: PageActionStep[] + selectionText: string + pageUrl: string | undefined + useQueryUrl: boolean + needClipboard: boolean + useClipboard: boolean | undefined + pageHtml: string | undefined + selectionHtml: string | undefined +}): Promise => { + const { + serviceUrl, + steps, + selectionText, + pageUrl, + useQueryUrl, + needClipboard, + useClipboard, + pageHtml, + selectionHtml, + } = params + + const pending: SidePanelPendingAction = { + url: serviceUrl, + steps, + selectedText: selectionText, + srcUrl: pageUrl ?? "", + clipboardText: "", + useClipboard: !useQueryUrl && (needClipboard || (useClipboard ?? false)), + pageHtml, + selectionHtml, + } + try { + await Storage.set( + SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING, + pending, + ) + } catch (e) { + console.error("Failed to store pending side panel action:", e) + return + } + Ipc.send(BgCommand.openSidePanel, { + url: serviceUrl, + }) +} + +// Resolves the effective open mode, swapping TAB/WINDOW when the secondary +// action (e.g. modifier-key click) is used. +const resolveOpenMode = ( + baseOpenMode: OPEN_MODE, + useSecondary: boolean | undefined, +): PAGE_ACTION_OPEN_MODE => { + const baseMode = toPageActionMode(baseOpenMode) + if (!useSecondary) return baseMode + if (baseMode === PAGE_ACTION_OPEN_MODE.TAB) { + return PAGE_ACTION_OPEN_MODE.WINDOW + } + return PAGE_ACTION_OPEN_MODE.TAB +} + export const AiPrompt = { async execute({ selectionText, @@ -89,295 +479,43 @@ export const AiPrompt = { return } - // Checks if any step requires clipboard data - const needClipboard = aiPromptOption.prompt.includes( - toInsertTemplate(INSERT.CLIPBOARD), - ) - - // Detect HTML placeholders that require file-paste upload instead of text embedding. - // File paste cannot be used with queryUrl mode (URL length limit), so these force - // the DOM input path. - const needPageHtml = aiPromptOption.prompt.includes( - toInsertTemplate(INSERT.PAGE_HTML), - ) - const pageHtml = needPageHtml ? getPageHtml() : undefined - - // Only one HTML attachment is allowed per execution; PAGE_HTML takes - // priority over SELECTION_HTML when the prompt contains both. - const needSelectionHtml = - !needPageHtml && - aiPromptOption.prompt.includes(toInsertTemplate(INSERT.SELECTION_HTML)) - const selectionHtml = needSelectionHtml ? getSelectionHtml() : undefined - - const needFilePaste = needPageHtml || needSelectionHtml - - // Use URL query input when the service supports it and neither clipboard nor - // file-paste content is needed. Both clipboard and HTML content require the DOM - // input path since they can't be embedded in a URL safely. - const useQueryUrl = - isValidString(service.queryUrl) && !needClipboard && !needFilePaste - - let steps: PageActionStep[] - let serviceUrl: string - let urlParam: UrlParam - - if (useQueryUrl) { - // Pre-expand the prompt template with synchronously available variables. - // INSERT.CLIPBOARD is intentionally excluded here: clipboard text is not - // available in the content script context and must be read asynchronously - // in the background. When the prompt contains {{Clipboard}}, useQueryUrl - // is false and the DOM input approach is used instead. - const expandedPrompt = safeInterpolate(aiPromptOption.prompt, { - [InsertSymbol[INSERT.SELECTED_TEXT]]: selectionText, - [InsertSymbol[INSERT.URL]]: pageUrl ?? "", - [InsertSymbol[INSERT.LANG]]: getUILanguage(), - }) + const { + needClipboard, + needPageHtml, + pageHtml, + needSelectionHtml, + selectionHtml, + needFilePaste, + useQueryUrl, + } = analyzePromptRequirements(aiPromptOption, service) - const finalPrompt = service.urlToMarkdown - ? convertUrlsToMarkdown(expandedPrompt) - : expandedPrompt - - urlParam = { - searchUrl: service.queryUrl!, - selectionText: finalPrompt, - useClipboard: false, - } - // Resolve the final URL for cases that require a plain string (e.g. side panel). - serviceUrl = toUrl(urlParam) as string - - // Build steps without the DOM input step. - const submitSelector = service.submitSelectors.join(", ") - const inputSelector = service.inputSelectors.join(", ") - if (!service.autoSubmit && submitSelector.length === 0) { - console.warn( - `[AiPrompt] queryUrl mode: submitSelectors is empty for "${service.id}" but autoSubmit is false. Submit step will be skipped.`, + const { steps, urlParam, serviceUrl } = useQueryUrl + ? buildQueryUrlSteps(service, aiPromptOption, selectionText, pageUrl) + : buildDomInputSteps( + service, + aiPromptOption, + selectionText, + needClipboard, + useClipboard, + needPageHtml, + needSelectionHtml, + needFilePaste, ) - } - - // When autoSubmit is true (e.g. ChatGPT, Perplexity) the service is - // expected to process the prompt automatically after navigation. But - // this can silently fail to fire (e.g. while ChatGPT is re-validating - // the session right after opening the URL), leaving the prompt sitting - // unsent in the input. As a fallback, add a Submit click step that only - // fires if the input still contains the unsent prompt. - let submitStep: PageActionStep[] = [] - if ( - service.autoSubmit && - submitSelector.length > 0 && - inputSelector.length > 0 - ) { - console.debug( - `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}". Adding fallback Submit step.`, - ) - submitStep = [ - { - id: generateRandomID(), - delayMs: 1000, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.click, - label: "Submit (fallback)", - selector: submitSelector, - selectorType: SelectorType.css, - condition: { - actionType: PAGE_ACTION_CONDITION_ACTION.skip, - conditionType: PAGE_ACTION_CONDITION_TYPE.empty, - selector: inputSelector, - selectorType: SelectorType.css, - }, - }, - }, - ] - } else if (!service.autoSubmit && submitSelector.length > 0) { - submitStep = [ - { - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.click, - label: "Submit", - selector: submitSelector, - selectorType: SelectorType.css, - }, - }, - ] - } else if (service.autoSubmit) { - console.warn( - `[AiPrompt] queryUrl mode: autoSubmit is true for "${service.id}" but submitSelectors or inputSelectors is empty. Fallback Submit step will be skipped.`, - ) - } - - steps = [ - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.start, - label: "Start", - mode: "aiPrompt", - }, - }, - ...submitStep, - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.end, - label: "End", - }, - }, - ] - } else { - // DOM input approach: type the prompt into the service's input element. - const inputSelector = service.inputSelectors.join(", ") - const submitSelector = service.submitSelectors.join(", ") - - serviceUrl = service.url - urlParam = { - searchUrl: service.url, - selectionText, - useClipboard: needClipboard || (useClipboard ?? false), - } - - // When HTML placeholders are present, build filePaste steps that upload - // the HTML as a text file attachment before typing the prompt. - // The placeholders are stripped from the input step value so the AI - // receives the file as an attachment and the remaining text as the prompt. - const pageTitle = - document.title - .replace(/[<>:"/\\|?*]/g, "") - .replace(/\p{Cc}/gu, "") - .trim() - .replace(/\s+/g, "-") - .slice(0, 64) || "page" - - const filePasteSteps: PageActionStep[] = [] - if (needPageHtml) { - filePasteSteps.push({ - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.filePaste, - label: "Paste page HTML", - selector: inputSelector, - selectorType: SelectorType.css, - value: toInsertTemplate(INSERT.PAGE_HTML), - fileName: `${pageTitle}.html`, - fileType: "text/html", - }, - }) - } - if (needSelectionHtml) { - filePasteSteps.push({ - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.filePaste, - label: "Paste selection HTML", - selector: inputSelector, - selectorType: SelectorType.css, - value: toInsertTemplate(INSERT.SELECTION_HTML), - fileName: `${pageTitle}.html`, - fileType: "text/html", - }, - }) - } - - // Remove the HTML placeholders from the prompt value. - const promptValue = needFilePaste - ? aiPromptOption.prompt - .replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "") - .replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "") - .trim() - : aiPromptOption.prompt - - steps = [ - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.start, - label: "Start", - mode: "aiPrompt", - }, - }, - ...filePasteSteps, - { - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.input, - label: "Input prompt", - selector: inputSelector, - selectorType: SelectorType.css, - value: promptValue, - }, - }, - { - id: generateRandomID(), - delayMs: 200, - skipRenderWait: false, - param: { - type: PAGE_ACTION_EVENT.click, - label: "Submit", - selector: submitSelector, - selectorType: SelectorType.css, - condition: { - actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil, - conditionType: PAGE_ACTION_CONDITION_TYPE.clickable, - selector: submitSelector, - selectorType: SelectorType.css, - timeout: PAGE_ACTION_TIMEOUT * 2, // Allow more time for click - }, - }, - }, - { - id: generateRandomID(), - delayMs: 0, - skipRenderWait: false, - param: { - type: PAGE_ACTION_CONTROL.end, - label: "End", - }, - }, - ] - } // Handle side panel mode: store pending steps in session storage, then open // the side panel. The background onConnect handler will pick up the pending // steps when the side panel content script establishes a port connection. - // Clipboard reading is deferred to the background script context to avoid - // browser security restrictions on navigator.clipboard in content scripts. if (aiPromptOption.openMode === OPEN_MODE.SIDE_PANEL) { - const pending: SidePanelPendingAction = { - url: serviceUrl, + await runSidePanelAction({ + serviceUrl, steps, - selectedText: selectionText, - srcUrl: pageUrl ?? "", - clipboardText: "", - useClipboard: - !useQueryUrl && (needClipboard || (useClipboard ?? false)), + selectionText, + pageUrl, + useQueryUrl, + needClipboard, + useClipboard, pageHtml, selectionHtml, - } - try { - await Storage.set( - SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING, - pending, - ) - } catch (e) { - console.error("Failed to store pending side panel action:", e) - return - } - Ipc.send(BgCommand.openSidePanel, { - url: serviceUrl, }) return } @@ -388,15 +526,7 @@ export const AiPrompt = { return } - const baseMode = toPageActionMode(aiPromptOption.openMode) - const openMode = useSecondary - ? baseMode === PAGE_ACTION_OPEN_MODE.TAB - ? PAGE_ACTION_OPEN_MODE.WINDOW - : baseMode === PAGE_ACTION_OPEN_MODE.WINDOW - ? PAGE_ACTION_OPEN_MODE.TAB - : PAGE_ACTION_OPEN_MODE.TAB - : baseMode - + const openMode = resolveOpenMode(aiPromptOption.openMode, useSecondary) const windowPosition = await getWindowPosition() Ipc.send(BgCommand.openAndRunPageAction, { diff --git a/packages/extension/src/background_script.test.ts b/packages/extension/src/background_script.test.ts index 587632e5..8d0d4a86 100644 --- a/packages/extension/src/background_script.test.ts +++ b/packages/extension/src/background_script.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { enhancedSettings } from "@/services/settings/enhancedSettings" import { Settings } from "@/services/settings/settings" import { BgCommand } from "@/services/ipc" +import { NEW_HUB_URL } from "@/const" // Mock dependencies vi.mock("@/services/settings/enhancedSettings") @@ -567,3 +568,131 @@ describe("Popup Auto-Close Delay", () => { expect(mockRemoveWindow).not.toHaveBeenCalled() }) }) + +describe("onInstalled: installed analytics event", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("IN-01: sends the installed event when reason is install", async () => { + const mockSendEvent = vi.fn() + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: mockSendEvent, + getOrCreateClientId: vi.fn().mockResolvedValue("test-client-id"), + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + expect(listenerCalls.length).toBeGreaterThan(0) + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.INSTALL, + }) + + expect(mockSendEvent).toHaveBeenCalledWith("installed", {}, "ServiceWorker") + }) + + it("IN-02: does not send the installed event when reason is update", async () => { + const mockSendEvent = vi.fn() + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: mockSendEvent, + getOrCreateClientId: vi.fn().mockResolvedValue("test-client-id"), + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.UPDATE, + }) + + expect(mockSendEvent).not.toHaveBeenCalledWith( + "installed", + expect.anything(), + expect.anything(), + ) + }) +}) + +describe("Uninstall URL (onInstalled)", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("UN-01: should set uninstall URL with client_id on install", async () => { + const mockGetOrCreateClientId = vi.fn().mockResolvedValue("test-client-id") + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: vi.fn(), + getOrCreateClientId: mockGetOrCreateClientId, + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + expect(listenerCalls.length).toBeGreaterThan(0) + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.INSTALL, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockGetOrCreateClientId).toHaveBeenCalled() + expect(chrome.runtime.setUninstallURL).toHaveBeenCalledWith( + `${NEW_HUB_URL}/uninstall?client_id=test-client-id`, + ) + }) + + it("UN-02: should fall back to the uninstall URL without client_id when getOrCreateClientId fails, without skipping the rest of initialization", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}) + const mockGetOrCreateClientId = vi + .fn() + .mockRejectedValue(new Error("Quota exceeded")) + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: vi.fn(), + getOrCreateClientId: mockGetOrCreateClientId, + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.INSTALL, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Falls back to the URL without client_id instead of leaving the + // uninstall URL unset. + expect(chrome.runtime.setUninstallURL).toHaveBeenCalledWith( + `${NEW_HUB_URL}/uninstall`, + ) + // The client_id failure must not propagate to the outer catch, which + // would otherwise skip the daily/weekly backup checks that follow. + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + "Error during onInstalled initialization:", + expect.anything(), + ) + + consoleErrorSpy.mockRestore() + }) +}) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 04311103..6994bbfa 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -22,7 +22,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 } from "@/services/analytics" +import { + ANALYTICS_EVENTS, + sendEvent, + getOrCreateClientId, +} from "@/services/analytics" import * as HubBackground from "@/services/hub/background" import { importIf } from "@import-if" @@ -440,6 +444,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { // Initialize default settings on install if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { await Settings.reset() + sendEvent(ANALYTICS_EVENTS.INSTALLED, {}, SCREEN.SERVICE_WORKER) } await ContextMenu.init() @@ -452,8 +457,18 @@ chrome.runtime.onInstalled.addListener(async (details) => { details.reason === chrome.runtime.OnInstalledReason.INSTALL || details.reason === chrome.runtime.OnInstalledReason.UPDATE ) { - // Set uninstall survey URL - chrome.runtime.setUninstallURL(`${NEW_HUB_URL}/uninstall`) + // Set uninstall survey URL with client_id for analysis. + // Wrapped in its own try/catch so a failure here (e.g. storage quota + // error) does not skip the backup checks below. + try { + const clientId = await getOrCreateClientId() + chrome.runtime.setUninstallURL( + `${NEW_HUB_URL}/uninstall?client_id=${clientId}`, + ) + } catch (error) { + console.error("Failed to set uninstall URL with client_id:", error) + chrome.runtime.setUninstallURL(`${NEW_HUB_URL}/uninstall`) + } } // Check for daily backup on startup diff --git a/packages/extension/src/components/NewCommandHubBridge.test.tsx b/packages/extension/src/components/NewCommandHubBridge.test.tsx new file mode 100644 index 00000000..368f6d7b --- /dev/null +++ b/packages/extension/src/components/NewCommandHubBridge.test.tsx @@ -0,0 +1,37 @@ +import { render } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { NewCommandHubBridge } from "./NewCommandHubBridge" +import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" +import { sendEvent } from "@/services/analytics" + +vi.mock("@/hooks/useCommandHubBridge", () => ({ + useCommandHubBridge: vi.fn(), +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + HUB_SCREEN_OPENED: "hub_screen_opened", + }, + sendEvent: vi.fn(), +})) + +const mockUseCommandHubBridge = vi.mocked(useCommandHubBridge) +const mockSendEvent = vi.mocked(sendEvent) + +describe("NewCommandHubBridge: hub_screen_opened analytics", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("NCH-01: activates the hub bridge hook and sends the event once", () => { + render() + + expect(mockUseCommandHubBridge).toHaveBeenCalled() + expect(mockSendEvent).toHaveBeenCalledTimes(1) + expect(mockSendEvent).toHaveBeenCalledWith( + "hub_screen_opened", + {}, + "CommandHub", + ) + }) +}) diff --git a/packages/extension/src/components/NewCommandHubBridge.tsx b/packages/extension/src/components/NewCommandHubBridge.tsx new file mode 100644 index 00000000..332cc9e3 --- /dev/null +++ b/packages/extension/src/components/NewCommandHubBridge.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react" +import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { SCREEN } from "@/const" + +/** Minimal React component that activates the hub bridge hook. */ +export function NewCommandHubBridge(): JSX.Element { + useCommandHubBridge() + useEffect(() => { + sendEvent(ANALYTICS_EVENTS.HUB_SCREEN_OPENED, {}, SCREEN.COMMAND_HUB) + }, []) + return <> +} diff --git a/packages/extension/src/components/ReviewRequestToast.tsx b/packages/extension/src/components/ReviewRequestToast.tsx index fd01822f..cd8b9811 100644 --- a/packages/extension/src/components/ReviewRequestToast.tsx +++ b/packages/extension/src/components/ReviewRequestToast.tsx @@ -29,7 +29,7 @@ export function showReviewRequestToast(onAccept: () => void): void { toast.custom( (toastId) => ( -
+
diff --git a/packages/extension/src/components/option/HubBanner.tsx b/packages/extension/src/components/option/HubBanner.tsx index 0c85b3b3..0acc45e0 100644 --- a/packages/extension/src/components/option/HubBanner.tsx +++ b/packages/extension/src/components/option/HubBanner.tsx @@ -26,7 +26,7 @@ export function HubBanner() {

Selection Command ({ + toast: { + custom: vi.fn(), + dismiss: vi.fn(), + }, +})) + +vi.mock("@/services/i18n", () => ({ + t: vi.fn((key: string) => { + const translations: Record = { + hub_share_toast_message: "Share this command to the Hub?", + hub_share_toast_later: "Later", + hub_share_toast_button: "Share", + } + return translations[key] || key + }), +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPEN_DIALOG: "open_dialog", + COMMAND_SHARE: "command_share", + }, + sendEvent: vi.fn(), +})) + +vi.mock("@/services/hubShare", () => ({ + shareCommandToHub: vi.fn(), + isHubRegistered: vi.fn(), +})) + +const mockToastCustom = vi.mocked(toast.custom) +const mockToastDismiss = vi.mocked(toast.dismiss) +const mockSendEvent = vi.mocked(sendEvent) +const mockShareCommandToHub = vi.mocked(shareCommandToHub) +const mockIsHubRegistered = vi.mocked(isHubRegistered) + +const command: SearchCommand = { + id: "cmd-1", + title: "Test Command", + iconUrl: "https://example.com/icon.png", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://google.com/search?q=%s", +} + +// Renders the JSX passed to toast.custom() so its buttons can be interacted with. +function renderToastBody(toastId: string | number = "toast-1") { + const [renderFn] = mockToastCustom.mock.calls[0] + return render(<>{renderFn(toastId)}) +} + +describe("showHubShareToast", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("HST-01: sends an open_dialog analytics event after the show delay", () => { + showHubShareToast(command, vi.fn()) + expect(mockSendEvent).not.toHaveBeenCalled() + + vi.advanceTimersByTime(2000) + + expect(mockSendEvent).toHaveBeenCalledWith( + "open_dialog", + { event_label: "hub_share_toast" }, + expect.anything(), + ) + }) + + it("HST-02: calls toast.custom with a 60 second duration after the show delay", () => { + showHubShareToast(command, vi.fn()) + expect(mockToastCustom).not.toHaveBeenCalled() + + vi.advanceTimersByTime(2000) + + expect(mockToastCustom).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ duration: 60 * 1000 }), + ) + }) + + it("HST-03: renders the message and both buttons", () => { + showHubShareToast(command, vi.fn()) + vi.advanceTimersByTime(2000) + renderToastBody() + + expect( + screen.getByText("Share this command to the Hub?"), + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Later" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Share/ })).toBeInTheDocument() + }) + + it("HST-04: clicking 'Later' dismisses the toast and calls onShown without sharing", async () => { + const onShown = vi.fn() + showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + vi.useRealTimers() + const user = userEvent.setup() + renderToastBody("toast-1") + + await user.click(screen.getByRole("button", { name: "Later" })) + + expect(mockToastDismiss).toHaveBeenCalledWith("toast-1") + expect(onShown).toHaveBeenCalledOnce() + expect(mockShareCommandToHub).not.toHaveBeenCalled() + }) + + it("HST-05: clicking 'Share' shares the command, dismisses the toast, and calls onShown", async () => { + mockIsHubRegistered.mockResolvedValue(true) + const onShown = vi.fn() + showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + vi.useRealTimers() + const user = userEvent.setup() + renderToastBody("toast-1") + + await user.click(screen.getByRole("button", { name: /Share/ })) + + expect(mockShareCommandToHub).toHaveBeenCalledWith(command) + expect(mockSendEvent).toHaveBeenCalledWith( + "command_share", + { event_label: "hub-share-toast" }, + expect.anything(), + ) + expect(mockToastDismiss).toHaveBeenCalledWith("toast-1") + expect(onShown).toHaveBeenCalledOnce() + }) + + it("HST-07: clicking 'Share' as an unregistered user skips the analytics event", async () => { + mockIsHubRegistered.mockResolvedValue(false) + const onShown = vi.fn() + showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + vi.useRealTimers() + const user = userEvent.setup() + renderToastBody("toast-1") + + await user.click(screen.getByRole("button", { name: /Share/ })) + + expect(mockShareCommandToHub).toHaveBeenCalledWith(command) + expect(mockSendEvent).not.toHaveBeenCalledWith( + "command_share", + expect.anything(), + expect.anything(), + ) + expect(mockToastDismiss).toHaveBeenCalledWith("toast-1") + expect(onShown).toHaveBeenCalledOnce() + }) + + it("HST-06: calls onShown when the toast auto-closes via timeout", () => { + const onShown = vi.fn() + showHubShareToast(command, onShown) + vi.advanceTimersByTime(2000) + + const [, options] = mockToastCustom.mock.calls[0] + options?.onAutoClose?.({} as never) + + expect(onShown).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/extension/src/components/option/HubShareToast.tsx b/packages/extension/src/components/option/HubShareToast.tsx new file mode 100644 index 00000000..6c9831a2 --- /dev/null +++ b/packages/extension/src/components/option/HubShareToast.tsx @@ -0,0 +1,109 @@ +import { toast } from "sonner" +import { Share } from "lucide-react" +import { t } from "@/services/i18n" +import { cn } from "@/lib/utils" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { SCREEN } from "@/const" +import { shareCommandToHub, isHubRegistered } from "@/services/hubShare" +import type { SelectionCommand } from "@/types" + +// Delay before showing the toast so it doesn't overlap the dialog's close animation. +const SHOW_DELAY_MS = 2000 + +/** + * Shows a one-time toast on the options page suggesting the user share a + * newly created command to the Selection Command Hub. `onShown` is invoked + * whenever the toast is dismissed — by clicking either button, or via the + * auto-close timeout — so callers can persist a "don't show again" flag as + * soon as the toast has been presented. + */ +export function showHubShareToast( + command: SelectionCommand, + onShown: () => void, +): void { + setTimeout(() => { + showHubShareToastNow(command, onShown) + }, SHOW_DELAY_MS) +} + +function showHubShareToastNow( + command: SelectionCommand, + onShown: () => void, +): void { + sendEvent( + ANALYTICS_EVENTS.OPEN_DIALOG, + { + event_label: "hub_share_toast", + }, + SCREEN.OPTION, + ) + + toast.custom( + (toastId) => ( +
+
+ party popper +
+ + {t("hub_share_toast_title")} + + {t("hub_share_toast_message")} +
+
+
+ + +
+
+ ), + { + duration: 60 * 1000, + onAutoClose: () => { + onShown() + }, + }, + ) +} diff --git a/packages/extension/src/components/option/ImportExport.tsx b/packages/extension/src/components/option/ImportExport.tsx index 15da5cab..c894f5d1 100644 --- a/packages/extension/src/components/option/ImportExport.tsx +++ b/packages/extension/src/components/option/ImportExport.tsx @@ -269,12 +269,14 @@ export function ImportExport() { commandExecutionCount = 0, hasShownReviewRequest = false, hasDismissedPromptHistoryBanner = false, + hasShownHubShareToast = false, } = await enhancedSettings.get() const data = await migrate({ ...importJson, commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, stars: [], }) await Settings.set(data) diff --git a/packages/extension/src/components/option/Option.test.tsx b/packages/extension/src/components/option/Option.test.tsx new file mode 100644 index 00000000..52134185 --- /dev/null +++ b/packages/extension/src/components/option/Option.test.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { render } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { Option } from "./Option" +import { sendEvent } from "@/services/analytics" + +vi.mock("@/services/settings/settings", () => ({ + Settings: { + addChangedListener: vi.fn(), + removeChangedListener: vi.fn(), + }, +})) + +vi.mock("@/components/Popup", () => ({ Popup: () => null })) +vi.mock("@/components/option/TableOfContents", () => ({ + TableOfContents: () => null, +})) +vi.mock("@/components/option/ImportExport", () => ({ + ImportExport: () => null, +})) +vi.mock("@/components/option/UserSupport", () => ({ UserSupport: () => null })) +vi.mock("@/components/option/DeveloperSupport", () => ({ + DeveloperSupport: () => null, +})) +vi.mock("@/components/option/HubBanner", () => ({ HubBanner: () => null })) +vi.mock("@/components/option/HubUserInfo", () => ({ HubUserInfo: () => null })) +vi.mock("@/components/option/SettingForm", () => ({ SettingForm: () => null })) +vi.mock("@/components/option/StorageUsage", () => ({ default: () => null })) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPTION_SCREEN_OPENED: "option_screen_opened", + }, + sendEvent: vi.fn(), +})) + +const mockSendEvent = vi.mocked(sendEvent) + +describe("Option: option_screen_opened analytics", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("OPT-01: sends the event exactly once on mount", () => { + render(
+

{APP_ID.split("-").map((n) => { diff --git a/packages/extension/src/components/option/SettingForm.tsx b/packages/extension/src/components/option/SettingForm.tsx index e107db85..121a1da7 100644 --- a/packages/extension/src/components/option/SettingForm.tsx +++ b/packages/extension/src/components/option/SettingForm.tsx @@ -479,18 +479,20 @@ export function SettingForm({ className }: { className?: string }) { )} {startupMethod !== STARTUP_METHOD.CONTEXT_MENU && ( - - )} + <> + - + + + )}
@@ -576,25 +578,25 @@ export function SettingForm({ className }: { className?: string }) { )} {linkCommandMethod === LINK_COMMAND_STARTUP_METHOD.LEFT_CLICK_HOLD && ( - - )} + + )} void @@ -35,9 +26,11 @@ export const ShareButton = ({ isShared, }: Props) => { const buttonRef = useRef(null) - const [status, setStatus] = useState<"idle" | "sent" | "error">("idle") + const [status, setStatus] = useState<"idle" | "pending" | "sent" | "error">( + "idle", + ) - const handleClick = (e: React.MouseEvent) => { + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() if (isShared) { @@ -48,6 +41,10 @@ export const ShareButton = ({ return } + // Disable the button immediately to prevent duplicate shares/signup + // tabs from rapid repeated clicks while the lookup below runs. + setStatus("pending") + let commandToShare = command if (!isUUIDv7(command.id)) { const newId = generateId() @@ -56,26 +53,34 @@ export const ShareButton = ({ } const ok = shareCommandToHub(commandToShare) - setStatus(ok ? "sent" : "error") - setTimeout(() => setStatus("idle"), 2000) - if (ok) { - sendEvent( - ANALYTICS_EVENTS.COMMAND_SHARE, - { event_label: "share-button" }, - SCREEN.OPTION, - ) + if (!ok) { + setStatus("error") + setTimeout(() => setStatus("idle"), 2000) + return } + + // Users who have never signed in to the hub are redirected to the + // sign-up page instead (see shareCommandToHub in services/hub/background.ts); + // nothing is actually shared yet, so leave the button idle and skip + // the share analytics event for this case. + const registered = await isHubRegistered() + if (!registered) { + setStatus("idle") + return + } + + setStatus("sent") + setTimeout(() => setStatus("idle"), 2000) + + sendEvent( + ANALYTICS_EVENTS.COMMAND_SHARE, + { event_label: "share-button" }, + SCREEN.OPTION, + ) } - if ( - !IS_SUPPORT_BUILD && - (HUB_SHARE_EXCLUDED_IDS.has(command.id) || - !NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) || - !VALID_SOURCE_TYPES.has( - command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN, - )) - ) { + if (!isHubShareable(command)) { return null } diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index f17c2041..52d0d8b9 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -120,13 +120,13 @@ const getDefault = ( ) => { const sourceDefaults = isEmpty(base?.id) ? { - sourceType: base?.sourceType ?? COMMAND_SOURCE_TYPE.SELF_CREATED, - sourceId: base?.sourceId ?? COMMAND_SOURCE_ID.SELF_CREATED, - } + sourceType: base?.sourceType ?? COMMAND_SOURCE_TYPE.SELF_CREATED, + sourceId: base?.sourceId ?? COMMAND_SOURCE_ID.SELF_CREATED, + } : { - sourceType: base?.sourceType, - sourceId: base?.sourceId, - } + sourceType: base?.sourceType, + sourceId: base?.sourceId, + } if (isSearchOpenMode(openMode)) { if (isSearchOpenMode(preOpenMode)) { @@ -610,6 +610,7 @@ const CommandEditDialogInner = ({ const parsedUrl = parseGeminiUrl(value) if (parsedUrl !== value) { setValue("searchUrl", parsedUrl) + clearErrors("searchUrl") } field.onBlur() }} diff --git a/packages/extension/src/components/option/editor/CommandList.analytics.test.tsx b/packages/extension/src/components/option/editor/CommandList.analytics.test.tsx new file mode 100644 index 00000000..8a00c8a4 --- /dev/null +++ b/packages/extension/src/components/option/editor/CommandList.analytics.test.tsx @@ -0,0 +1,198 @@ +import { render, screen } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import userEvent from "@testing-library/user-event" +import { useForm } from "react-hook-form" +import { CommandList } from "./CommandList" +import { isHubShareable } from "@/services/hubShare" +import { enhancedSettings } from "@/services/settings/enhancedSettings" +import { sendEvent } from "@/services/analytics" +import { OPEN_MODE } from "@/const" +import type { SelectionCommand, CommandFolder } from "@/types" + +// Renders as plain buttons so the test can trigger onAddCommand/onAddFolder +// directly, bypassing the real menu UI. +vi.mock("./CommandListMenu", () => ({ + CommandListMenu: (props: any) => ( + <> + + + + ), +})) + +vi.mock("@/components/option/editor/CommandTypeSelectionDialog", () => ({ + CommandTypeSelectionDialog: (props: any) => + props.open ? ( + <> + + + + ) : null, +})) + +let nextSubmittedCommand: SelectionCommand | null = null + +vi.mock("@/components/option/editor/CommandEditDialog", () => ({ + CommandEditDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +let nextSubmittedFolder: CommandFolder | null = null + +vi.mock("@/components/option/editor/FolderEditDialog", () => ({ + FolderEditDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +vi.mock("./CommandTreeRenderer", () => ({ + CommandTreeRenderer: () => null, +})) + +vi.mock("@dnd-kit/core", () => ({ + DndContext: (props: any) => <>{props.children}, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + PointerSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), +})) + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: (props: any) => <>{props.children}, + sortableKeyboardCoordinates: vi.fn(), + verticalListSortingStrategy: {}, +})) + +vi.mock("@/hooks/option/useCommandActions", () => ({ + useCommandActions: () => ({ commandRemove: vi.fn() }), +})) + +vi.mock("@/hooks/option/useCommandDragDrop", () => ({ + useCommandDragDrop: () => ({ handleDragEnd: vi.fn() }), +})) + +vi.mock("@/hooks/option/useSharedCommandIds", () => ({ + useSharedCommandIds: () => new Set(), +})) + +vi.mock("@/services/hubShare", () => ({ + editCommandToHub: vi.fn(), + isHubShareable: vi.fn(), +})) + +vi.mock("@/components/option/HubShareToast", () => ({ + showHubShareToast: vi.fn(), +})) + +vi.mock("@/services/settings/enhancedSettings", () => ({ + enhancedSettings: { + getSection: vi.fn(), + }, +})) + +vi.mock("@/services/settings/settings", () => ({ + Settings: { + update: vi.fn(), + updateCommandId: vi.fn(), + }, +})) + +// Use the real getCommandCreateEvent mapping so this test also verifies +// CommandList wires the correct category-specific event, while keeping the +// network-calling sendEvent stubbed out. +vi.mock("@/services/analytics", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + sendEvent: vi.fn(), + } +}) + +const mockIsHubShareable = vi.mocked(isHubShareable) +const mockGetSection = vi.mocked(enhancedSettings.getSection) +const mockSendEvent = vi.mocked(sendEvent) + +function Wrapper() { + const { control } = useForm({ + defaultValues: { commands: [], folders: [] }, + }) + return +} + +describe("CommandList: command_create_*/folder_create analytics", () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSection.mockResolvedValue({ hasShownHubShareToast: true } as any) + mockIsHubShareable.mockReturnValue(false) + }) + + it("CA-01: sends command_create_search when a new search command is created", async () => { + nextSubmittedCommand = { + id: "new-cmd-1", + title: "New Search Command", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://example.com/search?q=%s", + } as SelectionCommand + + const user = userEvent.setup() + render() + + await user.click(screen.getByText("add-command")) + await user.click(screen.getByText("select-type-search")) + await user.click(screen.getByText("submit-command")) + + expect(mockSendEvent).toHaveBeenCalledWith( + "command_create_search", + { event_label: OPEN_MODE.POPUP }, + "Option", + ) + }) + + it("CA-02: sends command_create_aiprompt when a new AI prompt command is created", async () => { + nextSubmittedCommand = { + id: "new-cmd-2", + title: "New AI Command", + openMode: OPEN_MODE.AI_PROMPT, + } as SelectionCommand + + const user = userEvent.setup() + render() + + await user.click(screen.getByText("add-command")) + await user.click(screen.getByText("select-type-aiprompt")) + await user.click(screen.getByText("submit-command")) + + expect(mockSendEvent).toHaveBeenCalledWith( + "command_create_aiprompt", + { event_label: OPEN_MODE.AI_PROMPT }, + "Option", + ) + }) + + it("CA-03: sends folder_create when a new folder is created", async () => { + nextSubmittedFolder = { + id: "new-folder-1", + title: "New Folder", + } + + const user = userEvent.setup() + render() + + await user.click(screen.getByText("add-folder")) + await user.click(screen.getByText("submit-folder")) + + expect(mockSendEvent).toHaveBeenCalledWith("folder_create", {}, "Option") + }) +}) diff --git a/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx new file mode 100644 index 00000000..a304797d --- /dev/null +++ b/packages/extension/src/components/option/editor/CommandList.hubShareToast.test.tsx @@ -0,0 +1,201 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import userEvent from "@testing-library/user-event" +import { useForm } from "react-hook-form" +import { CommandList } from "./CommandList" +import { isHubShareable } from "@/services/hubShare" +import { showHubShareToast } from "@/components/option/HubShareToast" +import { enhancedSettings } from "@/services/settings/enhancedSettings" +import { Settings } from "@/services/settings/settings" +import { OPEN_MODE } from "@/const" +import type { SelectionCommand } from "@/types" + +// Renders as a plain button so the test can trigger onAddCommand directly, +// bypassing the real menu UI. +vi.mock("./CommandListMenu", () => ({ + CommandListMenu: (props: any) => ( + + ), +})) + +vi.mock("@/components/option/editor/CommandTypeSelectionDialog", () => ({ + CommandTypeSelectionDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +let nextSubmittedCommand: SelectionCommand | null = null + +vi.mock("@/components/option/editor/CommandEditDialog", () => ({ + CommandEditDialog: (props: any) => + props.open ? ( + + ) : null, +})) + +vi.mock("@/components/option/editor/FolderEditDialog", () => ({ + FolderEditDialog: () => null, +})) + +vi.mock("./CommandTreeRenderer", () => ({ + CommandTreeRenderer: () => null, +})) + +vi.mock("@dnd-kit/core", () => ({ + DndContext: (props: any) => <>{props.children}, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + PointerSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), +})) + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: (props: any) => <>{props.children}, + sortableKeyboardCoordinates: vi.fn(), + verticalListSortingStrategy: {}, +})) + +vi.mock("@/hooks/option/useCommandActions", () => ({ + useCommandActions: () => ({ commandRemove: vi.fn() }), +})) + +vi.mock("@/hooks/option/useCommandDragDrop", () => ({ + useCommandDragDrop: () => ({ handleDragEnd: vi.fn() }), +})) + +vi.mock("@/hooks/option/useSharedCommandIds", () => ({ + useSharedCommandIds: () => new Set(), +})) + +vi.mock("@/services/hubShare", () => ({ + editCommandToHub: vi.fn(), + isHubShareable: vi.fn(), +})) + +vi.mock("@/components/option/HubShareToast", () => ({ + showHubShareToast: vi.fn(), +})) + +vi.mock("@/services/settings/enhancedSettings", () => ({ + enhancedSettings: { + getSection: vi.fn(), + }, +})) + +vi.mock("@/services/settings/settings", () => ({ + Settings: { + update: vi.fn(), + updateCommandId: vi.fn(), + }, +})) + +vi.mock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { + OPEN_DIALOG: "open_dialog", + COMMAND_CREATE_SEARCH: "command_create_search", + COMMAND_CREATE_AIPROMPT: "command_create_aiprompt", + COMMAND_CREATE_OTHER: "command_create_other", + COMMAND_EDIT: "command_edit", + COMMAND_REMOVE: "command_remove", + FOLDER_CREATE: "folder_create", + }, + sendEvent: vi.fn(), + getCommandCreateEvent: vi.fn(() => "command_create_other"), +})) + +const mockIsHubShareable = vi.mocked(isHubShareable) +const mockShowHubShareToast = vi.mocked(showHubShareToast) +const mockGetSection = vi.mocked(enhancedSettings.getSection) +const mockSettingsUpdate = vi.mocked(Settings.update) + +function Wrapper() { + const { control } = useForm({ + defaultValues: { commands: [], folders: [] }, + }) + return +} + +const newCommand: SelectionCommand = { + id: "new-cmd-1", + title: "New Command", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://example.com/search?q=%s", +} as SelectionCommand + +async function addNewCommand(user: ReturnType) { + await user.click(screen.getByText("add-command")) + await user.click(screen.getByText("select-type")) + await user.click(screen.getByText("submit-command")) +} + +describe("CommandList: hub share toast trigger on new command creation", () => { + beforeEach(() => { + vi.clearAllMocks() + nextSubmittedCommand = newCommand + mockGetSection.mockResolvedValue({ hasShownHubShareToast: false } as any) + mockIsHubShareable.mockReturnValue(true) + }) + + it("CL-01: shows the hub share toast when the command is shareable and not yet shown", async () => { + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockShowHubShareToast).toHaveBeenCalledWith( + newCommand, + expect.any(Function), + ) + }) + }) + + it("CL-02: does not show the toast when hasShownHubShareToast is already true", async () => { + mockGetSection.mockResolvedValue({ hasShownHubShareToast: true } as any) + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockGetSection).toHaveBeenCalled() + }) + expect(mockShowHubShareToast).not.toHaveBeenCalled() + }) + + it("CL-03: does not show the toast when the command is not hub-shareable", async () => { + mockIsHubShareable.mockReturnValue(false) + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockGetSection).toHaveBeenCalled() + }) + expect(mockShowHubShareToast).not.toHaveBeenCalled() + }) + + it("CL-04: the onShown callback persists the hasShownHubShareToast flag", async () => { + const user = userEvent.setup() + render() + + await addNewCommand(user) + + await waitFor(() => { + expect(mockShowHubShareToast).toHaveBeenCalled() + }) + + const onShown = mockShowHubShareToast.mock.calls[0][1] + onShown() + + expect(mockSettingsUpdate).toHaveBeenCalledWith( + "hasShownHubShareToast", + expect.any(Function), + ) + }) +}) diff --git a/packages/extension/src/components/option/editor/CommandList.tsx b/packages/extension/src/components/option/editor/CommandList.tsx index e0f1125d..c4356f20 100644 --- a/packages/extension/src/components/option/editor/CommandList.tsx +++ b/packages/extension/src/components/option/editor/CommandList.tsx @@ -26,7 +26,11 @@ import { FoldersSchemaType, } from "@/types/schema" -import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { + ANALYTICS_EVENTS, + sendEvent, + getCommandCreateEvent, +} from "@/services/analytics" import { SCREEN, COMMAND_TYPE, OPEN_MODE_TYPE_MAP } from "@/const" import type { Command, CommandFolder, SelectionCommand } from "@/types" @@ -42,7 +46,10 @@ import { getDescendantFolderIds, } from "@/services/option/commandUtils" import { isValidDrop } from "@/services/option/dragAndDrop" -import { editCommandToHub } from "@/services/hubShare" +import { editCommandToHub, isHubShareable } from "@/services/hubShare" +import { showHubShareToast } from "@/components/option/HubShareToast" +import { CACHE_SECTIONS } from "@/services/settings/settingsCache" +import { enhancedSettings } from "@/services/settings/enhancedSettings" import { Settings } from "@/services/settings/settings" import { useCommandActions } from "@/hooks/option/useCommandActions" import { useCommandDragDrop } from "@/hooks/option/useCommandDragDrop" @@ -234,12 +241,30 @@ export const CommandList = ({ control }: CommandListProps) => { } else { commandArray.append(data as CommandSchemaType) sendEvent( - ANALYTICS_EVENTS.COMMAND_ADD, + getCommandCreateEvent(data.openMode), { event_label: data.openMode, }, SCREEN.OPTION, ) + enhancedSettings + .getSection(CACHE_SECTIONS.USER_STATS) + .then((userStats) => { + if ( + !userStats.hasShownHubShareToast && + isHubShareable(data as SelectionCommand) + ) { + showHubShareToast(data as SelectionCommand, () => { + Settings.update("hasShownHubShareToast", () => true) + }) + } + }) + .catch((err) => { + console.error( + "[CommandList] Failed to load user stats for hub share toast:", + err, + ) + }) } } else { const idx = folderArray.fields.findIndex((f) => f.id === data.id) @@ -247,6 +272,7 @@ export const CommandList = ({ control }: CommandListProps) => { folderArray.update(idx, data) } else { folderArray.append(data) + sendEvent(ANALYTICS_EVENTS.FOLDER_CREATE, {}, SCREEN.OPTION) } } } diff --git a/packages/extension/src/components/option/editor/CommandListMenu.tsx b/packages/extension/src/components/option/editor/CommandListMenu.tsx index f9050cac..26ab24bd 100644 --- a/packages/extension/src/components/option/editor/CommandListMenu.tsx +++ b/packages/extension/src/components/option/editor/CommandListMenu.tsx @@ -1,16 +1,11 @@ import React from "react" import { Button } from "@/components/ui/button" -import { Terminal, FolderPlus, Search } from "lucide-react" +import { Terminal, FolderPlus } from "lucide-react" import { Tooltip } from "@/components/Tooltip" -import { NEW_HUB_URL } from "@/const" import { t as _t } from "@/services/i18n" import { TEST_IDS } from "@/testIds" -import { useHubUser } from "@/hooks/option/useHubUser" -import { getHubLocale } from "@/services/hubShare" -import { UTM_SOURCE, UTM_MEDIUM, withUtmParams } from "@shared" -const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) -const UTM_PARAMS = { source: UTM_SOURCE.OPTION_PAGE, medium: UTM_MEDIUM.BUTTON } +const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) interface Props { onAddCommand: () => void @@ -27,66 +22,44 @@ export const CommandListMenu: React.FC = ({ addFolderButtonRef, commandCount, }) => { - const hubUser = useHubUser() - const locale = getHubLocale() - const hubButtonLink = withUtmParams( - hubUser - ? `${NEW_HUB_URL}/${locale}/dashboard/commands` - : `${NEW_HUB_URL}/${locale}`, - UTM_PARAMS, - ) - return ( - ) } diff --git a/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx b/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx index 85b33909..eccf5f7f 100644 --- a/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandTypeSelectionDialog.tsx @@ -1,3 +1,4 @@ +import { Search } from "lucide-react" import { Dialog, DialogContent, @@ -5,7 +6,16 @@ import { DialogHeader, DialogTitle, DialogPortal, + DialogFooter, + DialogClose, } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" + +import { NEW_HUB_URL } from "@/const" +import { useHubUser } from "@/hooks/option/useHubUser" +import { getHubLocale } from "@/services/hubShare" +import { UTM_SOURCE, UTM_MEDIUM, withUtmParams } from "@shared" +const UTM_PARAMS = { source: UTM_SOURCE.OPTION_PAGE, medium: UTM_MEDIUM.BUTTON } import { CommandType } from "./CommandType" @@ -25,6 +35,15 @@ export const CommandTypeSelectionDialog = ({ onOpenChange, onSelect, }: CommandTypeSelectionDialogProps) => { + const hubUser = useHubUser() + const locale = getHubLocale() + const hubButtonLink = withUtmParams( + hubUser + ? `${NEW_HUB_URL}/${locale}/dashboard/commands` + : `${NEW_HUB_URL}/${locale}`, + UTM_PARAMS, + ) + const handleCardClick = (type: COMMAND_TYPE) => { onSelect(type) onOpenChange(false) @@ -34,12 +53,32 @@ export const CommandTypeSelectionDialog = ({ - - {t("commandType_title")} - - {t("commandType_description")} - - +
+ + {t("commandType_title")} + + {t("commandType_description")} + + + +
{COMMAND_TYPE_GROUPS.map((group) => (
@@ -59,6 +98,13 @@ export const CommandTypeSelectionDialog = ({
))}
+ + + + +
diff --git a/packages/extension/src/components/option/editor/PageRuleList.test.tsx b/packages/extension/src/components/option/editor/PageRuleList.test.tsx new file mode 100644 index 00000000..2f7c13c1 --- /dev/null +++ b/packages/extension/src/components/option/editor/PageRuleList.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, act } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import userEvent from "@testing-library/user-event" +import { useForm, FormProvider } from "react-hook-form" +import { PageRuleList } from "./PageRuleList" +import { sendEvent } from "@/services/analytics" +import { LINK_COMMAND_ENABLED, POPUP_ENABLED, INHERIT } from "@/const" +import { TEST_IDS } from "@/testIds" +import type { PageRule } from "@/types" + +// Use the real ANALYTICS_EVENTS/PAGE_RULE_CREATE constant so this test also +// verifies PageRuleList sends the right event name, while keeping the +// network-calling sendEvent stubbed out. +vi.mock("@/services/analytics", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + sendEvent: vi.fn(), + } +}) + +const mockSendEvent = vi.mocked(sendEvent) + +const existingRule: PageRule = { + urlPattern: "https://existing.example.com", + popupEnabled: POPUP_ENABLED.ENABLE, + popupPlacement: INHERIT, + linkCommandEnabled: LINK_COMMAND_ENABLED.INHERIT, +} + +function Wrapper({ pageRules = [] }: { pageRules?: PageRule[] }) { + const methods = useForm({ + defaultValues: { pageRules }, + }) + return ( + + + + ) +} + +describe("PageRuleList: page_rule_create analytics", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("PR-01: sends page_rule_create when a new page rule is created", async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId(TEST_IDS.pageRuleAddButton)) + // PageRuleDialog resets its form to DefaultRule via a 200ms setTimeout + // before the fields are usable; wait for it before typing. + await act(() => new Promise((resolve) => setTimeout(resolve, 250))) + await user.type( + screen.getByTestId(TEST_IDS.pageRuleUrlPatternInput), + "https://example.com", + ) + await user.click(screen.getByTestId(TEST_IDS.pageRuleSaveButton)) + + expect(mockSendEvent).toHaveBeenCalledWith("page_rule_create", {}, "Option") + }) + + it("PR-02: does not send page_rule_create when an existing page rule is edited", async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId(TEST_IDS.pageRuleEditButton)) + // Submit without changing the URL pattern, so upsert() matches the + // existing rule and takes the update branch instead of create. + await user.click(screen.getByTestId(TEST_IDS.pageRuleSaveButton)) + + expect(mockSendEvent).not.toHaveBeenCalledWith( + "page_rule_create", + expect.anything(), + expect.anything(), + ) + }) +}) diff --git a/packages/extension/src/components/option/editor/PageRuleList.tsx b/packages/extension/src/components/option/editor/PageRuleList.tsx index 34238cda..55803f90 100644 --- a/packages/extension/src/components/option/editor/PageRuleList.tsx +++ b/packages/extension/src/components/option/editor/PageRuleList.tsx @@ -43,10 +43,12 @@ import { PopupPlacementField } from "@/components/option/field/PopupPlacementFie import { PopupPlacement } from "@/services/option/defaultSettings" import { t as _t } from "@/services/i18n" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) -import { POPUP_ENABLED, LINK_COMMAND_ENABLED, INHERIT } from "@/const" +import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" +import { POPUP_ENABLED, LINK_COMMAND_ENABLED, INHERIT, SCREEN } from "@/const" import { e2a, cn, scrollToSelector } from "@/lib/utils" import type { PageRule, PopupPlacementOrInherit } from "@/types" import { popupPlacementSchema } from "@/types/schema" +import { TEST_IDS } from "@/testIds" import css from "@/components/ui/collapsible.module.css" @@ -234,6 +236,7 @@ export const PageRuleList = ({ ) if (index === -1) { pageRuleArray.append({ ...rule, createdAt: Date.now() }) + sendEvent(ANALYTICS_EVENTS.PAGE_RULE_CREATE, {}, SCREEN.OPTION) } else { const existingCreatedAt = pageRuleArray.fields[index].createdAt pageRuleArray.update(index, { ...rule, createdAt: existingCreatedAt }) @@ -262,6 +265,7 @@ export const PageRuleList = ({ className="px-2 rounded-md transition font-mono hover:bg-gray-100 hover:mr-1 hover:scale-[110%] group" onClick={() => setDialogOpen(true)} ref={addButtonRef} + data-testid={TEST_IDS.pageRuleAddButton} > {t("pageRules") @@ -361,6 +365,7 @@ export const PageRuleList = ({

{ editorRef.current = field setDialogOpen(true) @@ -413,6 +418,12 @@ export const PageRuleDialog = ({ const popupPlacement = watch("popupPlacement") const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(false) + const urlPatternInputProps = { + type: "string", + "data-testid": TEST_IDS.pageRuleUrlPatternInput, + ...register("urlPattern", {}), + } + const handlePopupPlacementSubmit = ( data: z.infer, ) => { @@ -457,10 +468,7 @@ export const PageRuleDialog = ({ control={form.control} name="urlPattern" formLabel={t("urlPattern")} - inputProps={{ - type: "string", - ...register("urlPattern", {}), - }} + inputProps={urlPatternInputProps} /> { onSubmit(data as PageRule) onOpenChange(false) diff --git a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx index 797b7f44..0503d883 100644 --- a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx +++ b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.test.tsx @@ -223,10 +223,24 @@ vi.mock("@/action/pageAction", () => ({ })) vi.mock("@/services/searchUrlAssist", () => ({ - searchUrlAssistAction: { + SEARCH_URL_ASSIST_SERVICE_ID: "gemini", + createSearchUrlAssistAction: vi.fn(() => ({ id: "search-url-assist", title: "Search URL Assist", - }, + })), +})) + +vi.mock("@/services/aiPrompt", () => ({ + findAiService: vi.fn().mockResolvedValue({ + id: "gemini", + name: "Gemini", + url: "https://gemini.google.com/app", + faviconUrl: "", + inputSelectors: [".ql-editor[contenteditable='true']"], + submitSelectors: ["[data-test-id='send-button-container'] button"], + copySelectors: ["copy-button button"], + selectorType: "css", + }), })) // Mock lib/utils diff --git a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx index 626fae5e..19672f4e 100644 --- a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx +++ b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx @@ -31,7 +31,11 @@ import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { t as _t } from "@/services/i18n" -import { searchUrlAssistAction } from "@/services/searchUrlAssist" +import { + createSearchUrlAssistAction, + SEARCH_URL_ASSIST_SERVICE_ID, +} from "@/services/searchUrlAssist" +import { findAiService } from "@/services/aiPrompt" import { PageAction } from "@/action/pageAction" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) @@ -71,7 +75,13 @@ export const SearchUrlAssistDialog = ({ setIsProcessing(true) try { - const command = searchUrlAssistAction + const service = await findAiService(SEARCH_URL_ASSIST_SERVICE_ID) + if (!service) { + console.error(`AI service not found: ${SEARCH_URL_ASSIST_SERVICE_ID}`) + return + } + + const command = createSearchUrlAssistAction(service) PageAction.execute({ selectionText: "", command, diff --git a/packages/extension/src/components/option/field/SwitchField.tsx b/packages/extension/src/components/option/field/SwitchField.tsx index 720fbff3..b02dab9d 100644 --- a/packages/extension/src/components/option/field/SwitchField.tsx +++ b/packages/extension/src/components/option/field/SwitchField.tsx @@ -37,10 +37,10 @@ export const SwitchField = ({ render={({ field }) => (
- - {formLabel} +
+ + {formLabel} + {tooltip && ( )} - +
{description && {description}} {tooltip && ( // Reverse mapping: COMMAND_TYPE -> OPEN_MODE[] export const COMMAND_TYPE_OPEN_MODES_MAP = { diff --git a/packages/extension/src/hooks/useSettings.test.tsx b/packages/extension/src/hooks/useSettings.test.tsx index 74b297be..15888080 100644 --- a/packages/extension/src/hooks/useSettings.test.tsx +++ b/packages/extension/src/hooks/useSettings.test.tsx @@ -375,6 +375,7 @@ describe("useSettings hooks", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) @@ -694,6 +695,7 @@ describe("useSettings hooks", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) diff --git a/packages/extension/src/new_command_hub.tsx b/packages/extension/src/new_command_hub.tsx index 6bad11f7..8dc9b179 100644 --- a/packages/extension/src/new_command_hub.tsx +++ b/packages/extension/src/new_command_hub.tsx @@ -1,11 +1,5 @@ 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 <> -} +import { NewCommandHubBridge } from "@/components/NewCommandHubBridge" const rootDiv = document.createElement("div") document.body.appendChild(rootDiv) diff --git a/packages/extension/src/services/aiPromptFallback.ts b/packages/extension/src/services/aiPromptFallback.ts index e36afc11..37c7a933 100644 --- a/packages/extension/src/services/aiPromptFallback.ts +++ b/packages/extension/src/services/aiPromptFallback.ts @@ -31,6 +31,7 @@ export const normalizeServices = (raw: unknown[]): AiService[] => { faviconUrl: s.faviconUrl ?? "", inputSelectors: s.inputSelectors ?? [], submitSelectors: s.submitSelectors ?? [], + copySelectors: s.copySelectors ?? [], selectorType: s.selectorType ?? SelectorType.css, queryUrl: s.queryUrl, autoSubmit: s.autoSubmit, diff --git a/packages/extension/src/services/analytics.test.ts b/packages/extension/src/services/analytics.test.ts new file mode 100644 index 00000000..74040f23 --- /dev/null +++ b/packages/extension/src/services/analytics.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest" +import { OPEN_MODE } from "@/const" +import { + ANALYTICS_EVENTS, + getCommandCreateEvent, + getHubAddEvent, +} from "@/services/analytics" + +describe("getCommandCreateEvent", () => { + it.each([ + [OPEN_MODE.POPUP, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.WINDOW, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.TAB, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.BACKGROUND_TAB, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.SIDE_PANEL, ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH], + [OPEN_MODE.AI_PROMPT, ANALYTICS_EVENTS.COMMAND_CREATE_AIPROMPT], + [OPEN_MODE.API, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.PAGE_ACTION, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.LINK_POPUP, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.COPY, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.GET_TEXT_STYLES, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.OPTION, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + [OPEN_MODE.ADD_PAGE_RULE, ANALYTICS_EVENTS.COMMAND_CREATE_OTHER], + ] as const)("maps openMode %s to %s", (openMode, expected) => { + expect(getCommandCreateEvent(openMode)).toBe(expected) + }) +}) + +describe("getHubAddEvent", () => { + it.each([ + [OPEN_MODE.POPUP, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.WINDOW, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.TAB, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.BACKGROUND_TAB, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.SIDE_PANEL, ANALYTICS_EVENTS.HUB_ADD_SEARCH], + [OPEN_MODE.AI_PROMPT, ANALYTICS_EVENTS.HUB_ADD_AIPROMPT], + [OPEN_MODE.API, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.PAGE_ACTION, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.LINK_POPUP, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.COPY, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.GET_TEXT_STYLES, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.OPTION, ANALYTICS_EVENTS.HUB_ADD_OTHER], + [OPEN_MODE.ADD_PAGE_RULE, ANALYTICS_EVENTS.HUB_ADD_OTHER], + ] as const)("maps openMode %s to %s", (openMode, expected) => { + expect(getHubAddEvent(openMode)).toBe(expected) + }) +}) diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index 65b562de..58c92607 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -1,6 +1,15 @@ import { Storage, LOCAL_STORAGE_KEY, SESSION_STORAGE_KEY } from "./storage" -import { isDebug, isE2E, APP_ID, VERSION, SCREEN } from "@/const" -import { SessionData } from "@/types" +import { + isDebug, + isE2E, + APP_ID, + VERSION, + SCREEN, + OPEN_MODE, + OPEN_MODE_TYPE_MAP, + COMMAND_TYPE, +} from "@/const" +import { SessionData, HubUser } from "@/types" const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect" const GA_DEBUG_ENDPOINT = "https://www.google-analytics.com/debug/mp/collect" @@ -15,24 +24,79 @@ const IS_CI = const DISABLE_ANALYTICS = IS_CI || isE2E export const ANALYTICS_EVENTS = { + INSTALLED: "installed", + OPTION_SCREEN_OPENED: "option_screen_opened", + HUB_SCREEN_OPENED: "hub_screen_opened", + COMMAND_CREATE_SEARCH: "command_create_search", + COMMAND_CREATE_AIPROMPT: "command_create_aiprompt", + COMMAND_CREATE_OTHER: "command_create_other", + HUB_ADD_SEARCH: "hub_add_search", + HUB_ADD_AIPROMPT: "hub_add_aiprompt", + HUB_ADD_OTHER: "hub_add_other", + // Kept as a single event (not split by search/aiprompt/other) to stay + // compatible with the execution-count aggregation on the Selection + // Command Hub side, which still reads the unsplit "selection_command" + // event name. SELECTION_COMMAND: "selection_command", LINK_COMMAND: "link_command", + FOLDER_CREATE: "folder_create", + PAGE_RULE_CREATE: "page_rule_create", SHORTCUT: "shortcut", SHOW_HELP: "show_help", SHOW_REVIEW_URL: "show_review_url", OPEN_DIALOG: "open_dialog", - COMMAND_ADD: "command_add", COMMAND_EDIT: "command_edit", COMMAND_REMOVE: "command_remove", COMMAND_SHARE: "command_share", - COMMAND_HUB_ADD: "command_hub_add", - COMMAND_HUB_STAR_ADD: "command_hub_star_add", - COMMAND_HUB_STAR_REMOVE: "command_hub_star_remove", } as const export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS] +// Coarse-grained category used to group per-command-type analytics events. +type CommandAnalyticsCategory = "search" | "aiprompt" | "other" + +function getCommandAnalyticsCategory( + openMode: OPEN_MODE, +): CommandAnalyticsCategory { + const type = OPEN_MODE_TYPE_MAP[openMode] + if (type === COMMAND_TYPE.SEARCH) return "search" + if (type === COMMAND_TYPE.AI_PROMPT) return "aiprompt" + return "other" +} + +// Per-command-type analytics events, keyed by the coarse category derived +// from getCommandAnalyticsCategory(). +const COMMAND_CREATE_EVENTS: Record< + CommandAnalyticsCategory, + AnalyticsEventName +> = { + search: ANALYTICS_EVENTS.COMMAND_CREATE_SEARCH, + aiprompt: ANALYTICS_EVENTS.COMMAND_CREATE_AIPROMPT, + other: ANALYTICS_EVENTS.COMMAND_CREATE_OTHER, +} + +const HUB_ADD_EVENTS: Record = { + search: ANALYTICS_EVENTS.HUB_ADD_SEARCH, + aiprompt: ANALYTICS_EVENTS.HUB_ADD_AIPROMPT, + other: ANALYTICS_EVENTS.HUB_ADD_OTHER, +} + +// Wraps a category->event lookup table into a getter keyed directly by +// OPEN_MODE, so callers don't need to know about CommandAnalyticsCategory. +function createCategoryEventGetter( + events: Record, +) { + return (openMode: OPEN_MODE): AnalyticsEventName => + events[getCommandAnalyticsCategory(openMode)] +} + +export const getCommandCreateEvent = createCategoryEventGetter( + COMMAND_CREATE_EVENTS, +) + +export const getHubAddEvent = createCategoryEventGetter(HUB_ADD_EVENTS) + // https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4 export async function sendEvent( @@ -40,6 +104,8 @@ export async function sendEvent( params: any, screen = SCREEN.CONTENT_SCRIPT, ) { + console.debug(`[analytics] sendEvent: ${name}`, params, screen) + // Do not send analytics data if running in CI or e2e build. if (DISABLE_ANALYTICS || !MEASUREMENT_ID || !API_SECRET) { return @@ -47,12 +113,17 @@ export async function sendEvent( const endpoint = isDebug ? GA_DEBUG_ENDPOINT : GA_ENDPOINT try { + const [{ clientId, userId }, sessionId] = await Promise.all([ + getClientIdAndUserId(), + getOrCreateSessionId(), + ]) const res = await fetch( `${endpoint}?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`, { method: "POST", body: JSON.stringify({ - client_id: await getOrCreateClientId(), + client_id: clientId, + ...(userId ? { user_id: userId } : {}), events: [ { name: name, @@ -60,13 +131,14 @@ export async function sendEvent( page_title: APP_ID, app_version: VERSION, screen: screen, - session_id: await getOrCreateSessionId(), + session_id: sessionId, engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC, ...params, }, }, ], }), + keepalive: true, }, ) if (isDebug) { @@ -77,6 +149,25 @@ export async function sendEvent( } } +// Reads CLIENT_ID and HUB_USER in a single chrome.storage.local.get call +// instead of two separate round trips, generating a client_id on first use. +async function getClientIdAndUserId(): Promise<{ + clientId: string + userId?: string +}> { + const result = await chrome.storage.local.get([ + LOCAL_STORAGE_KEY.CLIENT_ID, + LOCAL_STORAGE_KEY.HUB_USER, + ]) + let clientId = result[LOCAL_STORAGE_KEY.CLIENT_ID] as string | undefined + if (!clientId) { + clientId = crypto.randomUUID() + await Storage.set(LOCAL_STORAGE_KEY.CLIENT_ID, clientId) + } + const hubUser = result[LOCAL_STORAGE_KEY.HUB_USER] as HubUser | null + return { clientId, userId: hubUser?.id || undefined } +} + export async function getOrCreateClientId() { let clientId = await Storage.get(LOCAL_STORAGE_KEY.CLIENT_ID) if (!clientId) { diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index 20a4f95b..16bff316 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -21,9 +21,15 @@ vi.mock("@/services/storage", () => ({ getCommands: vi.fn(), setCommands: vi.fn(), set: vi.fn(), + get: vi.fn(), updateCommands: vi.fn(), + addListener: vi.fn(), + }, + LOCAL_STORAGE_KEY: { + HUB_USER: "hubUser", + HUB_SHARED_AT: "hubSharedAt", + HUB_REGISTERED: "hubRegistered", }, - LOCAL_STORAGE_KEY: { HUB_USER: "hubUser", HUB_SHARED_AT: "hubSharedAt" }, })) vi.mock("@/services/settings/settings", () => ({ @@ -32,11 +38,14 @@ vi.mock("@/services/settings/settings", () => ({ vi.mock("@/services/analytics", () => ({ ANALYTICS_EVENTS: { - COMMAND_ADD: "command_add", + HUB_ADD_SEARCH: "hub_add_search", + HUB_ADD_AIPROMPT: "hub_add_aiprompt", + HUB_ADD_OTHER: "hub_add_other", COMMAND_REMOVE: "command_remove", }, sendEvent: vi.fn(), getOrCreateClientId: vi.fn(), + getHubAddEvent: vi.fn(() => "hub_add_other"), })) vi.mock("@/const", async (importOriginal) => { @@ -123,6 +132,7 @@ beforeEach(() => { vi.mocked(Settings.updateCommandId).mockResolvedValue(undefined) vi.mocked(Storage.setCommands).mockResolvedValue(true) vi.mocked(Storage.set).mockResolvedValue(true) + vi.mocked(Storage.get).mockResolvedValue(true) vi.mocked(sendEvent).mockResolvedValue(undefined as any) vi.mocked(getOrCreateClientId).mockResolvedValue("client-id-123") ;(chrome.tabs as any).remove = vi.fn() @@ -744,7 +754,7 @@ describe("onMessageExternal - RequestInstalledCommand routing", () => { describe("handleSetSession", () => { it("SS-01: sets session, stores HubUser, and responds with result:true", async () => { mockSetSession.mockResolvedValue({ - data: { user: { email: "user@example.com" } }, + data: { user: { id: "user-id-123", email: "user@example.com" } }, error: null, }) const sendResponse = vi.fn() @@ -755,6 +765,7 @@ describe("handleSetSession", () => { refresh_token: "refresh-tok", }) expect(Storage.set).toHaveBeenCalledWith(LOCAL_STORAGE_KEY.HUB_USER, { + id: "user-id-123", name: "user@example.com", image: "", }) @@ -936,6 +947,23 @@ describe("shareCommandToHub", () => { ) }) + it("SH-02b: opens the sign-up page instead of the dashboard, and skips the port handshake, when the user has never registered", async () => { + vi.mocked(Storage.get).mockResolvedValue(false) + 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.tabs.create).toHaveBeenCalledWith( + { url: `${HUB_ORIGIN}/auth/signup` }, + expect.any(Function), + ) + expect(chrome.runtime.onConnectExternal.addListener).not.toHaveBeenCalled() + }) + it("SH-03: calls response(false) when tab.id is undefined", async () => { vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { cb?.({} as chrome.tabs.Tab) @@ -964,6 +992,12 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check (via isHubRegistered's own + // await), one for the tab-creation await that assigns tabId before the + // port-connect handler can match it. + await Promise.resolve() + await Promise.resolve() + const portConnectListener = vi.mocked( chrome.runtime.onConnectExternal.addListener, ).mock.calls[0][0] @@ -987,6 +1021,10 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Two ticks: one for the HUB_REGISTERED check (via isHubRegistered's own + // await), one for the tab-creation await that assigns tabId before the + // port-connect handler can match it. + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( @@ -1013,6 +1051,12 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( @@ -1057,6 +1101,12 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( @@ -1107,6 +1157,12 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() + await Promise.resolve() await Promise.resolve() const portConnectListener = vi.mocked( @@ -1159,6 +1215,12 @@ describe("shareCommandToHub", () => { const response = vi.fn() shareCommandToHub(param, sender, response) + // Three ticks: two for the HUB_REGISTERED check (isHubRegistered's own + // await plus the async-function-return microtask), one for the + // tab-creation await that assigns tabId before the port-connect handler + // can match it. + await Promise.resolve() + await Promise.resolve() 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 5a00ce23..dbbabbc4 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -9,12 +9,14 @@ import { import type { Sender } from "@/services/ipc" import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" import type { SubmitCommandInput } from "@/services/hubShare" +import { isHubRegistered } from "@/services/hubShare" import type { HubUser, CommandFromHub } from "@/types" import { Settings } from "@/services/settings/settings" import { ANALYTICS_EVENTS, sendEvent, getOrCreateClientId, + getHubAddEvent, } from "@/services/analytics" import { PopupOption } from "@/services/option/defaultSettings" import { @@ -79,6 +81,21 @@ export const shareCommandToHub = ( const share = async () => { try { + const registered = await isHubRegistered() + if (!registered) { + // Users who have never signed in to the hub have no account to + // share into yet. Send them to sign up instead of the dashboard, + // which would otherwise just bounce them to the login screen. + // The "hub-share" port handshake below is only implemented on the + // dashboard page, so it can't succeed here and is skipped. + const signupUrl = `${NEW_HUB_URL}/auth/signup` + const tab = await new Promise((resolve) => + chrome.tabs.create({ url: signupUrl }, resolve), + ) + response(!!tab?.id) + return + } + let currentParam = param let idRegenerateCount = 0 const MAX_ID_REGENERATE = 3 @@ -308,36 +325,36 @@ export async function handleAddCommand( const cmd = isSearch ? { - id: parsed.id, - title: parsed.title, - searchUrl: parsed.searchUrl, - iconUrl: parsed.iconUrl, - ...sourceInfo, - openMode: parsed.openMode, - openModeSecondary: parsed.openModeSecondary, - spaceEncoding: parsed.spaceEncoding, - popupOption: PopupOption, - } - : isAiPrompt - ? { id: parsed.id, title: parsed.title, + searchUrl: parsed.searchUrl, iconUrl: parsed.iconUrl, ...sourceInfo, openMode: parsed.openMode, - aiPromptOption: parsed.aiPromptOption, + openModeSecondary: parsed.openModeSecondary, + spaceEncoding: parsed.spaceEncoding, popupOption: PopupOption, } - : isPageAction - ? { + : isAiPrompt + ? { id: parsed.id, title: parsed.title, iconUrl: parsed.iconUrl, ...sourceInfo, openMode: parsed.openMode, - pageActionOption: parsed.pageActionOption, + aiPromptOption: parsed.aiPromptOption, popupOption: PopupOption, } + : isPageAction + ? { + id: parsed.id, + title: parsed.title, + iconUrl: parsed.iconUrl, + ...sourceInfo, + openMode: parsed.openMode, + pageActionOption: parsed.pageActionOption, + popupOption: PopupOption, + } : null if (!cmd) { @@ -349,7 +366,7 @@ export async function handleAddCommand( await Settings.addCommands([cmd]) console.debug("[handleAddCommand] Saved command id:", cmd.id) await sendEvent( - ANALYTICS_EVENTS.COMMAND_ADD, + getHubAddEvent(cmd.openMode), { event_label: cmd.openMode, source_type: sourceInfo.sourceType, @@ -416,7 +433,7 @@ export function handleEditCommand( ackTimeout: undefined, ackListener: undefined, pendingResponse: undefined, - cancelConnectWait: () => { }, + cancelConnectWait: () => {}, } _editSession = newSession @@ -511,10 +528,14 @@ export async function handleSetSession( return } const hubUser: HubUser = { + id: data.user.id, name: data.user.email ?? "", image: "", } await Storage.set(LOCAL_STORAGE_KEY.HUB_USER, hubUser) + // Once authenticated, remember it permanently (not cleared on sign-out) + // so the extension can tell "signed up before" apart from "signed in now". + await Storage.set(LOCAL_STORAGE_KEY.HUB_REGISTERED, true) sendResponse({ result: true }) } catch (err) { console.error("[handleSetSession] Failed:", err) diff --git a/packages/extension/src/services/hubShare.test.ts b/packages/extension/src/services/hubShare.test.ts index d395b585..532900bd 100644 --- a/packages/extension/src/services/hubShare.test.ts +++ b/packages/extension/src/services/hubShare.test.ts @@ -3,9 +3,10 @@ import { getHubLocale, toSubmitCommandInput, shareCommandToHub, + isHubShareable, } from "./hubShare" import { Ipc, BgCommand } from "@/services/ipc" -import { OPEN_MODE, PAGE_ACTION_OPEN_MODE } from "@/const" +import { OPEN_MODE, PAGE_ACTION_OPEN_MODE, COMMAND_SOURCE_TYPE } from "@/const" import type { SearchCommand, PageActionCommand, AiPromptCommand } from "@/types" // Mock the IPC module so that shareCommandToHub does not trigger real messaging @@ -292,3 +293,91 @@ describe("shareCommandToHub", () => { }) }) +// ---- isHubShareable --------------------------------------------------------- + +describe("isHubShareable", () => { + it("HS-01: returns false for a command in the exclusion list", () => { + const cmd = makeSearchCmd({ + id: "0", + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-02: returns false for a non-shareable openMode", () => { + const cmd = makeSearchCmd({ + openMode: OPEN_MODE.COPY, + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-03: returns false for a DEFAULT sourceType", () => { + const cmd = makeSearchCmd({ sourceType: COMMAND_SOURCE_TYPE.DEFAULT }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-04: returns false for a HUB_COMMUNITY sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.HUB_COMMUNITY, + }) + expect(isHubShareable(cmd)).toBe(false) + }) + + it("HS-05: returns true for a SELF_CREATED sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-06: returns true for a SELF_UPDATED sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_UPDATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-07: returns true for a SELF_REINSTALL sourceType", () => { + const cmd = makeSearchCmd({ + sourceType: COMMAND_SOURCE_TYPE.SELF_REINSTALL, + }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-08: treats a missing sourceType as UNKNOWN and returns true", () => { + const cmd = makeSearchCmd({ sourceType: undefined }) + expect(isHubShareable(cmd)).toBe(true) + }) + + it("HS-09: returns true when all eligibility conditions are met", () => { + const cmd = makeSearchCmd({ + id: "not-excluded", + openMode: OPEN_MODE.TAB, + sourceType: COMMAND_SOURCE_TYPE.SELF_CREATED, + }) + expect(isHubShareable(cmd)).toBe(true) + }) +}) + +describe("isHubShareable (support build)", () => { + beforeEach(() => { + vi.resetModules() + vi.stubEnv("VITE_SUPPORT_BUILD", "true") + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("HS-10: bypasses all eligibility checks when IS_SUPPORT_BUILD is true", async () => { + const { isHubShareable: isHubShareableSupportBuild } = + await import("./hubShare") + const cmd = makeSearchCmd({ + id: "0", // normally excluded + openMode: OPEN_MODE.COPY, // normally non-shareable + sourceType: COMMAND_SOURCE_TYPE.DEFAULT, // normally non-shareable + }) + expect(isHubShareableSupportBuild(cmd)).toBe(true) + }) +}) diff --git a/packages/extension/src/services/hubShare.ts b/packages/extension/src/services/hubShare.ts index 7265ba81..c3af64a0 100644 --- a/packages/extension/src/services/hubShare.ts +++ b/packages/extension/src/services/hubShare.ts @@ -1,9 +1,24 @@ -import { NEW_HUB_SUPPORTED_LOCALES, type NewHubLocale } from "@/const" +import { + NEW_HUB_SUPPORTED_LOCALES, + NEW_HUB_SHAREABLE_OPEN_MODES, + HUB_SHARE_EXCLUDED_IDS, + COMMAND_SOURCE_TYPE, + IS_SUPPORT_BUILD, + type NewHubLocale, +} from "@/const" import { getAiServicesFallback } from "@/services/aiPromptFallback" import { isAiPromptCommand, isPageActionCommand } from "@/lib/utils" import { Ipc, BgCommand } from "@/services/ipc" +import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" import type { SelectionCommand, SearchCommand } from "@/types" +const HUB_SHAREABLE_SOURCE_TYPES = new Set([ + COMMAND_SOURCE_TYPE.SELF_CREATED, + COMMAND_SOURCE_TYPE.SELF_UPDATED, + COMMAND_SOURCE_TYPE.SELF_REINSTALL, + COMMAND_SOURCE_TYPE.UNKNOWN, +]) + // ---- Type definitions ------------------------------------------------------ export type SubmitCommandInput = { @@ -60,6 +75,34 @@ export function toSubmitCommandInput( } } +// ---- Eligibility check ------------------------------------------------------ + +/** + * Determines whether a command is eligible to be shared to the Hub. + * Support builds bypass this check to make testing the share flow easier. + */ +export function isHubShareable(command: SelectionCommand): boolean { + if (IS_SUPPORT_BUILD) return true + + return ( + !HUB_SHARE_EXCLUDED_IDS.has(command.id) && + NEW_HUB_SHAREABLE_OPEN_MODES.has(command.openMode) && + HUB_SHAREABLE_SOURCE_TYPES.has( + command.sourceType ?? COMMAND_SOURCE_TYPE.UNKNOWN, + ) + ) +} + +/** + * Determines whether the user has ever signed in to the Hub. + * Used both to decide the ShareButton's success/idle display and to decide + * whether shareCommandToHub() opens the dashboard or the sign-up page, + * so keep this the single source of truth for that check. + */ +export async function isHubRegistered(): Promise { + return !!(await Storage.get(LOCAL_STORAGE_KEY.HUB_REGISTERED)) +} + // ---- Share main logic ------------------------------------------------------ export function shareCommandToHub(command: SelectionCommand): boolean { diff --git a/packages/extension/src/services/option/defaultSettings.ts b/packages/extension/src/services/option/defaultSettings.ts index 73ad617b..aa50c24f 100644 --- a/packages/extension/src/services/option/defaultSettings.ts +++ b/packages/extension/src/services/option/defaultSettings.ts @@ -66,6 +66,7 @@ export const emptySettings: SettingsType = { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, shortcuts: { shortcuts: [] }, windowOption: { sidePanelAutoHide: false, diff --git a/packages/extension/src/services/searchUrlAssist.ts b/packages/extension/src/services/searchUrlAssist.ts index e58afd25..d3452a24 100644 --- a/packages/extension/src/services/searchUrlAssist.ts +++ b/packages/extension/src/services/searchUrlAssist.ts @@ -1,84 +1,116 @@ -import { PAGE_ACTION_OPEN_MODE, OPEN_MODE } from "@/const" +import { + PAGE_ACTION_OPEN_MODE, + OPEN_MODE, + PAGE_ACTION_CONTROL, + PAGE_ACTION_EVENT, + SelectorType, +} from "@/const" +import type { AiService } from "@/types" -export const searchUrlAssistAction = { - title: "Search Assist AI", - id: "0bf16427-ff9d-456c-b505-67b468c781a7", - revision: 0, - iconUrl: - "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", - openMode: OPEN_MODE.PAGE_ACTION, - popupOption: { - height: 600, - width: 1000, - }, - pageActionOption: { - openMode: PAGE_ACTION_OPEN_MODE.POPUP, - startUrl: "https://gemini.google.com/app", - steps: [ - { - delayMs: 0, - id: "iiaew61ok", - param: { - label: "Start", - type: "start", +/** id of the Gemini entry in ai-services.json, used to look up its selectors. */ +export const SEARCH_URL_ASSIST_SERVICE_ID = "gemini" + +const PROMPT_TEMPLATE = + "User Input: Given a search result URL and the entered search keyword, generate a search URL template.\n\n# Generation steps\n1. Replace the value of the search keyword parameter with %s.\n2. Remove unnecessary query parameters that are unrelated to the search itself (e.g., ad tracking IDs, session IDs). Keep parameters required for functionality such as language or search settings.\n\n# User Input\n* Search keyword: {{search_keyword}}\n* Search result URL: {{search_result_url}}\n\n# Example of generation\n* Search keyword: test\n* Search result URL: https://www.google.com/search?q=test&rlz=1C5CHFA_enJP1116JP1116&oq=test&gs_lcrp=EgZjaHJvbWUqBggAEEUYOzIGCAAQRRg7MgYIARBFGDwyBggCEEUYPTIGCAMQRRhBMgYIBBBFGDzSAQgxOTE0ajBqN6gCALACAA&sourceid=chrome&ie=UTF-8\n* Search URL template: https://google.com/search?q=%s\n\n# Output format\nOutput only the search URL template in plain text. Do not include anything else.\nExample of valid output:\nhttps://google.com/search?q=%s" + +// Used when `service.copySelectors` is missing (e.g. a same-day cached +// AiService fetched before this field was introduced), so the step never +// ends up with an empty selector. +const FALLBACK_COPY_SELECTOR = "copy-button button" + +/** + * Build the Search URL Assist PageAction command for Gemini. + * Input/submit selectors are taken from `service` (sourced from + * ai-services.json) instead of being hardcoded, so this stays working + * across Gemini UI changes as long as ai-services.json is kept up to date. + */ +export const createSearchUrlAssistAction = (service: AiService) => { + const inputSelector = service.inputSelectors.join(", ") + const submitSelector = service.submitSelectors.join(", ") + + const copySelectors = service.copySelectors ?? [] + const copySelector = + copySelectors.length > 0 ? copySelectors.join(", ") : FALLBACK_COPY_SELECTOR + + return { + title: "Search Assist AI", + id: "0bf16427-ff9d-456c-b505-67b468c781a7", + revision: 0, + iconUrl: + "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", + openMode: OPEN_MODE.PAGE_ACTION, + popupOption: { + height: 600, + width: 1000, + }, + pageActionOption: { + openMode: PAGE_ACTION_OPEN_MODE.POPUP, + startUrl: service.url, + steps: [ + { + delayMs: 0, + id: "iiaew61ok", + param: { + label: "Start", + type: PAGE_ACTION_CONTROL.start, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "hbbcdmvz3", - param: { - label: "Focus textarea", - selector: "//*[@role='textbox']/*", - selectorType: "xpath", - type: "click", + { + delayMs: 0, + id: "hbbcdmvz3", + param: { + label: "Focus textarea", + selector: inputSelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.click, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "5f9h14pq1", - param: { - label: "Input prompt", - selector: "//*[@role='textbox']/*", - selectorType: "xpath", - type: "input", - value: - "User Input: Given a search result URL and the entered search keyword, generate a search URL template.\n\n# Generation steps\n1. Replace the value of the search keyword parameter with %s.\n2. Remove unnecessary query parameters that are unrelated to the search itself (e.g., ad tracking IDs, session IDs). Keep parameters required for functionality such as language or search settings.\n\n# User Input\n* Search keyword: {{search_keyword}}\n* Search result URL: {{search_result_url}}\n\n# Example of generation\n* Search keyword: test\n* Search result URL: https://www.google.com/search?q=test&rlz=1C5CHFA_enJP1116JP1116&oq=test&gs_lcrp=EgZjaHJvbWUqBggAEEUYOzIGCAAQRRg7MgYIARBFGDwyBggCEEUYPTIGCAMQRRhBMgYIBBBFGDzSAQgxOTE0ajBqN6gCALACAA&sourceid=chrome&ie=UTF-8\n* Search URL template: https://google.com/search?q=%s\n\n# Output format\nOutput only the search URL template in plain text. Do not include anything else.\nExample of valid output:\nhttps://google.com/search?q=%s", + { + delayMs: 0, + id: "5f9h14pq1", + param: { + label: "Input prompt", + selector: inputSelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.input, + value: PROMPT_TEMPLATE, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "cnkcck292", - param: { - label: "Submit", - selector: "//*[@data-mat-icon-name='send']", - selectorType: "xpath", - type: "click", + { + delayMs: 0, + id: "cnkcck292", + param: { + label: "Submit", + selector: submitSelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.click, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "2c30nv8o2", - param: { - label: "Copy to clipboard", - selector: "//*[@data-mat-icon-name='content_copy']", - selectorType: "xpath", - type: "click", + { + delayMs: 0, + id: "2c30nv8o2", + param: { + label: "Copy to clipboard", + selector: copySelector, + selectorType: SelectorType.css, + type: PAGE_ACTION_EVENT.click, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "f7nb0ge9b", - param: { - label: "End", - type: "end", + { + delayMs: 0, + id: "f7nb0ge9b", + param: { + label: "End", + type: PAGE_ACTION_CONTROL.end, + }, + skipRenderWait: false, }, - skipRenderWait: false, - }, - ], - }, + ], + }, + } } diff --git a/packages/extension/src/services/settings/enhancedSettings.ts b/packages/extension/src/services/settings/enhancedSettings.ts index 13fc93fb..0a5b1fe2 100644 --- a/packages/extension/src/services/settings/enhancedSettings.ts +++ b/packages/extension/src/services/settings/enhancedSettings.ts @@ -47,26 +47,27 @@ export class EnhancedSettings { : Promise.resolve([]), sections.includes(CACHE_SECTIONS.USER_SETTINGS) ? settingsCache.get( - CACHE_SECTIONS.USER_SETTINGS, - forceFresh, - ) + CACHE_SECTIONS.USER_SETTINGS, + forceFresh, + ) : Promise.resolve(DefaultSettings as UserSettings), sections.includes(CACHE_SECTIONS.STARS) ? settingsCache.get(CACHE_SECTIONS.STARS, forceFresh) : Promise.resolve([]), sections.includes(CACHE_SECTIONS.SHORTCUTS) ? settingsCache.get( - CACHE_SECTIONS.SHORTCUTS, - forceFresh, - ) + CACHE_SECTIONS.SHORTCUTS, + forceFresh, + ) : Promise.resolve({ shortcuts: [] }), sections.includes(CACHE_SECTIONS.USER_STATS) ? settingsCache.get(CACHE_SECTIONS.USER_STATS, forceFresh) : Promise.resolve({ - commandExecutionCount: 0, - hasShownReviewRequest: false, - hasDismissedPromptHistoryBanner: false, - }), + commandExecutionCount: 0, + hasShownReviewRequest: false, + hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, + }), ]) // Process results @@ -93,10 +94,11 @@ export class EnhancedSettings { userStatsResult.status === "fulfilled" ? userStatsResult.value : { - commandExecutionCount: 0, - hasShownReviewRequest: false, - hasDismissedPromptHistoryBanner: false, - } + commandExecutionCount: 0, + hasShownReviewRequest: false, + hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, + } // Merge settings const mergedSettings = this.mergeSettings({ @@ -128,16 +130,16 @@ export class EnhancedSettings { forceFresh = false, ): Promise< K extends "commands" - ? Command[] - : K extends "userSettings" - ? UserSettings - : K extends "stars" - ? Star[] - : K extends "shortcuts" - ? ShortcutSettings - : K extends "userStats" - ? UserStats - : any + ? Command[] + : K extends "userSettings" + ? UserSettings + : K extends "stars" + ? Star[] + : K extends "shortcuts" + ? ShortcutSettings + : K extends "userStats" + ? UserStats + : any > { if (section === CACHE_SECTIONS.COMMANDS) { let commands = await settingsCache.get( @@ -182,6 +184,7 @@ export class EnhancedSettings { hasShownReviewRequest: data.userStats.hasShownReviewRequest, hasDismissedPromptHistoryBanner: data.userStats.hasDismissedPromptHistoryBanner, + hasShownHubShareToast: data.userStats.hasShownHubShareToast, } as SettingsType } diff --git a/packages/extension/src/services/settings/settings.test.ts b/packages/extension/src/services/settings/settings.test.ts index b14910e3..39102bd3 100644 --- a/packages/extension/src/services/settings/settings.test.ts +++ b/packages/extension/src/services/settings/settings.test.ts @@ -193,6 +193,7 @@ describe("Settings", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, stars: [] as Star[], } @@ -213,6 +214,7 @@ describe("Settings", () => { hasShownReviewRequest: mockSettings.hasShownReviewRequest, hasDismissedPromptHistoryBanner: mockSettings.hasDismissedPromptHistoryBanner, + hasShownHubShareToast: mockSettings.hasShownHubShareToast, }) expect(mockStorage.set).toHaveBeenCalledWith( STORAGE_KEY.SHORTCUTS, @@ -632,6 +634,7 @@ describe("migrate function", () => { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, stars: [] as Star[], } diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts index f0ebd066..e70f8d69 100644 --- a/packages/extension/src/services/settings/settings.ts +++ b/packages/extension/src/services/settings/settings.ts @@ -137,6 +137,7 @@ export const Settings = { commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, stars, ...restData } = data @@ -145,6 +146,7 @@ export const Settings = { commandExecutionCount, hasShownReviewRequest, hasDismissedPromptHistoryBanner, + hasShownHubShareToast, } ps.push(Storage.set(STORAGE_KEY.USER_STATS, userStats)) diff --git a/packages/extension/src/services/sidePanelDetector.ts b/packages/extension/src/services/sidePanelDetector.ts index 606a4b06..91c653f3 100644 --- a/packages/extension/src/services/sidePanelDetector.ts +++ b/packages/extension/src/services/sidePanelDetector.ts @@ -17,6 +17,7 @@ export const isSidePanel = ( // Check if tab is in sidePanelTabs const bgData = BgData.get() + if (bgData?.sidePanelTabs == null) return false if (!bgData.sidePanelTabs.some((t) => t.tabId === activeTabId)) return false return true diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index e449e871..2a59c8bb 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -18,6 +18,7 @@ export enum LOCAL_STORAGE_KEY { WEEKLY_COMMANDS_BACKUP = "weeklyCommandsBackup", HUB_USER = "hubUser", HUB_SHARED_AT = "hubSharedAt", + HUB_REGISTERED = "hubRegistered", } export enum SESSION_STORAGE_KEY { diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 0b24e70d..713d05c7 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -57,6 +57,7 @@ const DEFAULTS = { commandExecutionCount: 0, hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, + hasShownHubShareToast: false, }, [STORAGE_KEY.SHORTCUTS]: { shortcuts: [], @@ -74,6 +75,7 @@ const DEFAULTS = { [LOCAL_STORAGE_KEY.LOCAL_COMMAND_METADATA]: null, [LOCAL_STORAGE_KEY.GLOBAL_COMMAND_METADATA]: null, [LOCAL_STORAGE_KEY.HUB_USER]: null, + [LOCAL_STORAGE_KEY.HUB_REGISTERED]: false, [SESSION_STORAGE_KEY.BG]: {}, [SESSION_STORAGE_KEY.SESSION_DATA]: null, [SESSION_STORAGE_KEY.MESSAGE_QUEUE]: [], diff --git a/packages/extension/src/test/setup.ts b/packages/extension/src/test/setup.ts index e5ec5e2b..b6b13059 100644 --- a/packages/extension/src/test/setup.ts +++ b/packages/extension/src/test/setup.ts @@ -276,9 +276,25 @@ global.chrome = { removeListener: vi.fn(), }, }, - storage: defaultStorageMocks, + storage: { + ...defaultStorageMocks, + session: { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + clear: vi.fn(), + setAccessLevel: vi.fn(), + }, + }, runtime: { sendMessage: vi.fn(), + setUninstallURL: vi.fn(), + OnInstalledReason: { + INSTALL: "install", + UPDATE: "update", + CHROME_UPDATE: "chrome_update", + SHARED_MODULE_UPDATE: "shared_module_update", + }, onInstalled: { addListener: vi.fn(), }, diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts index c3f5a848..75c427fc 100644 --- a/packages/extension/src/testIds.ts +++ b/packages/extension/src/testIds.ts @@ -23,4 +23,8 @@ export const TEST_IDS = { pageActionStep: (type: string) => `page-action-step-${type}`, selectTrigger: (name: string) => `select-trigger-${name.replace(/\./g, "-")}`, selectItem: (name: string) => `select-item-${name.replace(/\./g, "-")}`, + pageRuleAddButton: "page-rule-add-button", + pageRuleUrlPatternInput: "page-rule-url-pattern-input", + pageRuleSaveButton: "page-rule-save-button", + pageRuleEditButton: "page-rule-edit-button", } diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 67c3d92f..e2ee4498 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -180,6 +180,7 @@ export type UserStats = { commandExecutionCount: number hasShownReviewRequest: boolean hasDismissedPromptHistoryBanner: boolean + hasShownHubShareToast: boolean } export type ShortcutCommand = { @@ -214,6 +215,7 @@ export type SessionData = { } export type HubUser = { + id: string name: string image: string } @@ -339,6 +341,8 @@ export type AiService = { faviconUrl: string inputSelectors: string[] submitSelectors: string[] + /** Selectors for the button that copies the service's generated response to the clipboard. */ + copySelectors: string[] selectorType: SelectorType /** URL template with %s placeholder for prompt injection via URL query parameter. */ queryUrl?: string diff --git a/packages/extension/tailwind.config.js b/packages/extension/tailwind.config.js index f8f435dc..63dc22b7 100644 --- a/packages/extension/tailwind.config.js +++ b/packages/extension/tailwind.config.js @@ -30,6 +30,16 @@ module.exports = { "0%": { transform: "translateX(100%)" }, "100%": { transform: "translateX(0%)" }, }, + popup: { + "0%": { + opacity: "0", + transform: "scale(0.5) translate(-10px, 10px)", + }, + "100%": { + opacity: "1", + transform: "scale(1)", + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", @@ -37,6 +47,7 @@ module.exports = { marquee: "marquee var(--marquee-duration) linear infinite", marquee2: "marquee2 var(--marquee-duration) linear infinite", "spin-slow": "spin 3s linear infinite", + popup: "popup 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both", }, padding: { 0.5: "2px", diff --git a/packages/hub/package.json b/packages/hub/package.json index b78b435d..f8680855 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -16,7 +16,6 @@ "dependencies": { "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.24.0", diff --git a/packages/hub/public/data/ai-services.json b/packages/hub/public/data/ai-services.json index 959fe568..30834a01 100644 --- a/packages/hub/public/data/ai-services.json +++ b/packages/hub/public/data/ai-services.json @@ -6,10 +6,7 @@ "queryUrl": "https://chatgpt.com/#?q=%s", "autoSubmit": true, "faviconUrl": "https://chatgpt.com/favicon.ico", - "inputSelectors": [ - "#prompt-textarea", - "[data-testid='prompt-textarea']" - ], + "inputSelectors": ["#prompt-textarea", "[data-testid='prompt-textarea']"], "submitSelectors": [ "form button.composer-submit-button-color", "button#composer-submit-button", @@ -21,13 +18,15 @@ "name": "Gemini", "url": "https://gemini.google.com/app", "faviconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg", - "inputSelectors": [ - ".ql-editor[contenteditable='true']" - ], + "inputSelectors": [".ql-editor[contenteditable='true']"], "submitSelectors": [ "[data-test-id='send-button-container'] button", "button.send-button", "button mat-icon[fonticon='send']" + ], + "copySelectors": [ + "copy-button button", + "message-actions button[aria-label='Copy']" ] }, { diff --git a/packages/hub/src/components/ui/button.tsx b/packages/hub/src/components/ui/button.tsx index 44a2f5ec..9870f41a 100644 --- a/packages/hub/src/components/ui/button.tsx +++ b/packages/hub/src/components/ui/button.tsx @@ -1,5 +1,4 @@ import * as React from "react" -import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" @@ -35,16 +34,14 @@ const buttonVariants = cva( ) export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean -} + extends + React.ButtonHTMLAttributes, + VariantProps {} const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" + ({ className, variant, size, ...props }, ref) => { return ( -