diff --git a/docs/design/clipboard.md b/docs/design/clipboard.md new file mode 100644 index 000000000..38d69afc4 --- /dev/null +++ b/docs/design/clipboard.md @@ -0,0 +1,46 @@ +# クリップボード読み取り + +Chrome 116相当以降を対象とし、Service Workerからの読み取りは +`readClipboard()` → `readOffscreenClipboard()` に統一する。 +`clipboard.html` による表示タブ・ウィンドウでの読み取りは使用しない。 + +## 作成コストと再利用 + +- 最初の読み取りでOffscreen Documentを遅延作成する。 +- 読み取り後もドキュメントを維持し、次回以降は同じドキュメントを再利用する。 +- 読み取りは直列化し、同時要求による二重作成や結果の混在を防ぐ。 +- Service Worker再起動時も `runtime.getContexts()` で既存ドキュメントを検出する。 +- Chromeによってドキュメントが破棄された場合は、次の要求で再作成する。 +- textareaの内容は読み取り直後に消去し、クリップボード本文を保持しない。 +- 作成時に他のコンテキストが先に作成していた場合(単一ドキュメント制約のエラー)は、 + 既存ドキュメントをそのまま使用する。 + +## 失敗時の扱い + +- 読み取りに失敗した場合は50ms間隔で最大3回まで再試行する。 +- Offscreen Documentからの応答が `ClipboardResult` の形式でない場合はエラーとして扱う。 +- textareaにフォーカスできない場合は、空文字を成功として返さずエラーとする。 +- エラー時のトーストは、ユーザーが見ているタブへ表示する。 + バックグラウンドタブで開く場合は、表示先ではなく実行元のタブへ通知する。 + +Offscreen Documentを表示先のタブ・ウィンドウへ転用するAPIはないため、 +従来の「読み取り画面を表示先として転用する」最適化はそのまま移植できない。 +代わりに、読み取り側のドキュメントを要求間で再利用する設計とする。 + +従来は、表示先タブ・ウィンドウで読み取った後、その画面を目的URLへ遷移させていた。 +新方式では読み取り用のドキュメントを繰り返し再利用し、表示先は完成したURLで +一度だけ作成する。初回は非表示ドキュメントの作成待ちが必要だが、次回以降は +読み取りごとの作成・破棄と、表示先での中間ページの読み込みを避けられる。 +ブラウザによるレンダラープロセスの割り当ては制御しないため、 +初回を含めて常に高速になるとは保証しない。 + +## 読み取りAPI + +Offscreen Documentはフォーカスできない。検証環境では +`navigator.clipboard.readText()` は存在するものの、 +`Document is not focused` で失敗したため、`clipboardRead` 権限と +textareaへの `document.execCommand("paste")` を使用する。 +APIを将来変更する際は、非フォーカス状態での読み取りが成立することを確認する。 + +- [Offscreen API](https://developer.chrome.com/docs/extensions/reference/api/offscreen) +- [runtime.getContexts](https://developer.chrome.com/docs/extensions/reference/api/runtime#method-getContexts) diff --git a/docs/test/e2e-test-spec.md b/docs/test/e2e-test-spec.md index 25bb21573..832b66ffc 100644 --- a/docs/test/e2e-test-spec.md +++ b/docs/test/e2e-test-spec.md @@ -63,6 +63,17 @@ | E2E-90 | ハブ | PageActionコマンド | インストール | 実装済 | | E2E-91 | ハブ | 追加 | - | 実装済 | | E2E-92 | ハブ | 削除 | - | 実装済 | +| E2E-100 | ショートカット | 検索・選択文字列 | onCommand以降 | 実装済 | +| E2E-101 | ショートカット | 日本語・空白・記号 | onCommand以降 | 実装済 | +| E2E-102 | ショートカット | クリップボード | onCommand以降 | 実装済 | +| E2E-103 | ショートカット | DO_NOTHING | onCommand以降 | 実装済 | +| E2E-104 | ショートカット | Content Scriptなし | onCommand以降 | 実装済 | +| E2E-105 | ショートカット | 登録状態 | onCommand以降 | 実装済 | +| E2E-106 | ショートカット | PageAction / Popup | Clipboard / 未選択 | 実装済 | +| E2E-107 | ショートカット | PageAction / BackgroundTab | Clipboard / 未選択 | 実装済 | +| E2E-108 | ショートカット | PageAction / Popup | SelectedText / 選択あり | 実装済 | +| E2E-109 | ショートカット | PageAction / Popup | SelectedText / 未選択 | 実装済 | +| E2E-110 | ショートカット | PageAction / BackgroundTab | SelectedText / 未選択 | 実装済 | --- @@ -1048,3 +1059,47 @@ **期待動作** - 削除したコマンドに対応する Hub ページのダウンロードボタンが復活する(「追加済み」表示が解除される) + +### ショートカット(shortcut.spec.ts) + +`chrome.commands.onCommand` に登録する `handleShortcutCommand` 以降を検証する。 +E2EビルドでのみService Workerのグローバルへ関数を公開し、Playwrightの +`Worker.evaluate()` から呼び出す。IPCや外部Webページ向けの受付経路は追加しない。 +本番ビルドでは公開処理が除去される。OSのキー入力・Chromeのイベント発火、 +サイドパネルは対象外。各テストでOptionsPageから設定をインポートする。 + +| テストID | 手順 | 期待動作 | +| -------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- | +| E2E-100 | TestPageで見出しを選択しslot_1を実行 | 割り当てた検索コマンドの結果が開き、選択文字列が検索パラメータに渡る | +| E2E-101 | 日本語・空白・記号を選択しslot_1を実行 | URLエンコードされた文字列が正しく渡る | +| E2E-102 | 未選択状態でクリップボードへ文字列を設定しslot_1を実行 | クリップボードの文字列で検索する | +| E2E-103 | 未選択状態でDO_NOTHINGのslot_2を実行し処理完了を待つ | タブ数・ID・URLが変化しない | +| E2E-104 | クリップボードを設定しchrome://versionでslot_1を実行 | Content Scriptを使わずService Workerから検索する | +| E2E-105 | Service Workerでchrome.commands.getAll()を実行 | slot_1〜slot_3の登録を確認する。キー文字列の値はOSや競合に依存するため固定しない | + +#### PageActionの変数展開(E2E-106〜110) + +各ケースで入力ステップを含むPageActionをOptionsPageからインポートし、 +`slot_1` に割り当てる。未選択時の設定は `USE_CLIPBOARD` とする。 +TestPageを開き、実際のクリップボードに日本語・記号・改行と、配列を含む +TypeScriptコードを設定する。`[KeyA]`、`{Enter}`、`[]`、`[[`、`{{` も含め、 +キー操作として解釈されず、textareaに文字列のまま入力されることを検証する。 +ショートカット実行後、入力欄の値が `Input: ` と期待する文字列に一致するまで待機する。 +ウィンドウ種別がポップアップ/通常ウィンドウであることも検証する。 +テスト側で対象ページをアクティブ化する操作は行わない。 + +バックグラウンドタブでは `readClipboard()` がOffscreen Documentで読み取り、 +結果タブを非アクティブで作成する。E2E-107/110ではウィンドウが新規作成されないこと、 +結果タブの非アクティブ状態と元ページのフォーカス維持も検証する。 +全表示モードでOffscreenに統一し、Chrome 116相当以降を対応範囲とする。 +E2E-107ではクリップボードを書き換えて再実行し、最新の入力値と +Offscreen Documentの `documentId` が同一であること(再利用)も検証する。 +設計の詳細は [クリップボード読み取り](../design/clipboard.md) を参照。 + +| テストID | 選択状態 | 入力ステップ | 表示方法 | 期待する入力値の変数部分 | +| -------- | -------- | ------------------ | -------------------- | ------------------------------------------ | +| E2E-106 | 未選択 | `{{Clipboard}}` | ポップアップ | クリップボードの内容 | +| E2E-107 | 未選択 | `{{Clipboard}}` | バックグラウンドタブ | クリップボードの内容 | +| E2E-108 | 選択あり | `{{SelectedText}}` | ポップアップ | 選択テキスト(クリップボードとは異なる値) | +| E2E-109 | 未選択 | `{{SelectedText}}` | ポップアップ | クリップボードの内容 | +| E2E-110 | 未選択 | `{{SelectedText}}` | バックグラウンドタブ | クリップボードの内容 | diff --git a/docs/test/serviceWorker-test-design.md b/docs/test/serviceWorker-test-design.md index 6ce1f928d..a1c488904 100644 --- a/docs/test/serviceWorker-test-design.md +++ b/docs/test/serviceWorker-test-design.md @@ -130,7 +130,8 @@ **テキスト処理:** -- ✅ SWD-44: 正常系: selectedTextが空でuseClipboardがtrueの場合、clipboardTextを使用 +- ✅ SWD-44: 正常系: selectionFromClipboardがtrueの場合、clipboardTextをselectedTextとして使用 +- ✅ SWD-44-b: 正常系: ステップのみがクリップボードを使う場合(clipboardForSteps)、selectedTextは空のまま - ✅ SWD-45: 正常系: selectedTextが存在する場合はそのまま使用 - ✅ SWD-46: 正常系: userVariablesが正しく渡される diff --git a/packages/extension/e2e/link-preview.spec.ts b/packages/extension/e2e/link-preview.spec.ts index 237dc1024..177dc688d 100644 --- a/packages/extension/e2e/link-preview.spec.ts +++ b/packages/extension/e2e/link-preview.spec.ts @@ -30,7 +30,7 @@ test.describe("Link Preview", () => { const href = await link.getAttribute("href") expect(href).toBeTruthy() - // React 側が ready になってからネイティブ Shift+click + // Native Shift+click after the React side is ready const [previewPage] = await Promise.all([ context.waitForEvent("page", { timeout: 5000 }), await link.click({ diff --git a/packages/extension/e2e/shortcut.spec.ts b/packages/extension/e2e/shortcut.spec.ts new file mode 100644 index 000000000..c347578f8 --- /dev/null +++ b/packages/extension/e2e/shortcut.spec.ts @@ -0,0 +1,345 @@ +import type { BrowserContext } from "@playwright/test" +import { test, expect } from "./fixtures" +import { OptionsPage } from "./pages/OptionsPage" +import { TestPage } from "./pages/TestPage" +import { readFileSync } from "node:fs" + +const testSettings = JSON.parse( + readFileSync(new URL("./data/test-settings.json", import.meta.url), "utf-8"), +) + +async function runShortcut(context: BrowserContext) { + const worker = context.serviceWorkers()[0] + await worker.evaluate(async () => { + const workerGlobal = globalThis as typeof globalThis & { + __e2eHandleShortcutCommand: (commandName: string) => Promise + } + await workerGlobal.__e2eHandleShortcutCommand("slot_1") + }) +} + +async function expectSearch(context: BrowserContext, text: string) { + const resultPromise = context.waitForEvent("page") + await runShortcut(context) + const result = await resultPromise + await expect(result).toHaveURL( + `https://ujiro99.github.io/selection-command/en/test?k=${encodeURIComponent(text).replaceAll("%20", "+")}`, + ) +} + +test.describe("Search shortcuts", () => { + test.beforeEach(async ({ context, extensionId, getCommands }) => { + const options = new OptionsPage(context, extensionId, getCommands) + await options.importSettings({ + ...testSettings, + shortcuts: { + shortcuts: [ + { + id: "slot_1", + commandId: "13b4e831-7fac-4b72-a1ae-c82655b3819e", + noSelectionBehavior: "useClipboard", + }, + { + id: "slot_2", + commandId: "13b4e831-7fac-4b72-a1ae-c82655b3819e", + noSelectionBehavior: "doNothing", + }, + ], + }, + }) + await options.close() + }) + + test("E2E-100: slot_1 runs the search command with the selected text", async ({ + context, + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await page.bringToFront() + await testPage.selectText("h2") + const selection = await page.evaluate(() => + window.getSelection()!.toString(), + ) + expect(selection).not.toBe("") + await expectSearch(context, selection) + }) + + test("E2E-101: Japanese, spaces, and symbols in the selected text are passed to the search URL", async ({ + context, + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await page.bringToFront() + const text = "日本語 space & + ?" + await page + .locator("h2") + .first() + .evaluate((element, text) => { + element.textContent = text + const range = document.createRange() + range.selectNodeContents(element) + window.getSelection()!.removeAllRanges() + window.getSelection()!.addRange(range) + }, text) + await expectSearch(context, text) + }) + + test("E2E-102: Searches with the clipboard when nothing is selected", async ({ + context, + page, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await new TestPage(page).open() + await page.bringToFront() + const text = "clipboard 日本語 & test" + await page.evaluate(async (text) => { + window.getSelection()!.removeAllRanges() + await navigator.clipboard.writeText(text) + }, text) + await expectSearch(context, text) + }) + + test("E2E-103: Does nothing when nothing is selected with the DO_NOTHING setting", async ({ + context, + page, + }) => { + await new TestPage(page).open() + await page.bringToFront() + await page.evaluate(() => window.getSelection()!.removeAllRanges()) + const worker = context.serviceWorkers()[0] + const before = await worker.evaluate(() => chrome.tabs.query({})) + await worker.evaluate(async () => { + const workerGlobal = globalThis as typeof globalThis & { + __e2eHandleShortcutCommand: (commandName: string) => Promise + } + await workerGlobal.__e2eHandleShortcutCommand("slot_2") + }) + const after = await worker.evaluate(() => chrome.tabs.query({})) + expect(after.map(({ id, url }) => ({ id, url }))).toEqual( + before.map(({ id, url }) => ({ id, url })), + ) + }) + + test("E2E-104: Runs from the Service Worker on chrome:// pages without a content script", async ({ + context, + page, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await new TestPage(page).open() + await page.bringToFront() + const text = "fallback clipboard" + await page.evaluate((text) => navigator.clipboard.writeText(text), text) + await page.goto("chrome://version") + await expectSearch(context, text) + }) + + test("E2E-105: slot_1 to slot_3 are registered in chrome.commands.getAll", async ({ + context, + }) => { + const commands = await context + .serviceWorkers()[0] + .evaluate(() => chrome.commands.getAll()) + for (const name of ["slot_1", "slot_2", "slot_3"]) { + expect(commands).toContainEqual( + expect.objectContaining({ + name, + description: expect.any(String), + shortcut: expect.any(String), + }), + ) + } + }) +}) + +const pageActionCases = [ + { + id: "E2E-106", + openMode: "popup", + variable: "Clipboard", + hasSelection: false, + }, + { + id: "E2E-107", + openMode: "backgroundTab", + variable: "Clipboard", + hasSelection: false, + }, + { + id: "E2E-108", + openMode: "popup", + variable: "SelectedText", + hasSelection: true, + }, + { + id: "E2E-109", + openMode: "popup", + variable: "SelectedText", + hasSelection: false, + }, + { + id: "E2E-110", + openMode: "backgroundTab", + variable: "SelectedText", + hasSelection: false, + }, +] as const + +for (const { id, openMode, variable, hasSelection } of pageActionCases) { + test(`${id}: PageAction shortcut / ${openMode} / {{${variable}}} / ${hasSelection ? "with selection" : "no selection"}`, async ({ + context, + extensionId, + getCommands, + page, + }) => { + const targetUrl = "https://ujiro99.github.io/selection-command/en/test" + const commandId = "shortcut-page-action" + const options = new OptionsPage(context, extensionId, getCommands) + await options.importSettings({ + ...testSettings, + commands: [ + ...testSettings.commands, + { + id: commandId, + title: "Shortcut PageAction", + openMode: "pageAction", + popupOption: { width: 600, height: 700 }, + pageActionOption: { + startUrl: `${targetUrl}?shortcutPageAction=${id}`, + openMode, + steps: [ + { + id: "start", + param: { type: "start", label: "Start", url: targetUrl }, + }, + { + id: "input", + param: { + type: "input", + label: "Textarea", + selector: "//textarea", + selectorType: "xpath", + value: `Input: {{${variable}}}`, + }, + }, + { id: "end", param: { type: "end", label: "End" } }, + ], + }, + }, + ], + shortcuts: { + shortcuts: [ + { id: "slot_1", commandId, noSelectionBehavior: "useClipboard" }, + ], + }, + }) + await options.close() + + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + const testPage = new TestPage(page) + await testPage.open() + await page.bringToFront() + const clipboardText = `clipboard 日本語 & ${id} + const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [documentUrl], + }) +[KeyA] {Enter} [] [[ {{` + await page.evaluate(async (text) => { + window.getSelection()!.removeAllRanges() + await navigator.clipboard.writeText(text) + }, clipboardText) + + let expectedText = clipboardText + if (hasSelection) { + await testPage.selectText("h2") + expectedText = await page.evaluate(() => + window.getSelection()!.toString(), + ) + expect(expectedText).not.toBe("") + expect(expectedText).not.toBe(clipboardText) + } else { + expect(await page.evaluate(() => window.getSelection()!.toString())).toBe( + "", + ) + } + + if (openMode === "backgroundTab") { + await context.serviceWorkers()[0].evaluate(() => { + const workerGlobal = globalThis as typeof globalThis & { + createdWindowCount: number + } + workerGlobal.createdWindowCount = 0 + chrome.windows.onCreated.addListener( + () => workerGlobal.createdWindowCount++, + ) + }) + } + + const resultPromise = context.waitForEvent("page") + await runShortcut(context) + const result = await resultPromise + await expect(result).toHaveURL(`${targetUrl}?shortcutPageAction=${id}`) + await expect(result.locator("textarea")).toHaveValue( + `Input: ${expectedText}`, + { timeout: 10_000 }, + ) + + const resultTab = await context + .serviceWorkers()[0] + .evaluate(async (url) => { + const [tab] = await chrome.tabs.query({ url }) + return { + active: tab.active, + windowType: (await chrome.windows.get(tab.windowId)).type, + } + }, result.url()) + expect(resultTab.windowType).toBe(openMode === "popup" ? "popup" : "normal") + if (openMode === "backgroundTab") { + const createdWindowCount = await context + .serviceWorkers()[0] + .evaluate( + () => + (globalThis as typeof globalThis & { createdWindowCount: number }) + .createdWindowCount, + ) + expect(createdWindowCount).toBe(0) + expect(resultTab.active).toBe(false) + expect(await page.evaluate(() => document.hasFocus())).toBe(true) + if (id === "E2E-107") { + const worker = context.serviceWorkers()[0] + const getDocumentId = () => + worker.evaluate(async () => { + const contexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + }) + return contexts[0]?.documentId + }) + const documentId = await getDocumentId() + expect(documentId).toBeTruthy() + const nextText = "Updated clipboard [second] 日本語" + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + nextText, + ) + const nextResultPromise = context.waitForEvent("page") + await runShortcut(context) + const nextResult = await nextResultPromise + await expect(nextResult.locator("textarea")).toHaveValue( + `Input: ${nextText}`, + { timeout: 10_000 }, + ) + expect(await getDocumentId()).toBe(documentId) + expect(await page.evaluate(() => document.hasFocus())).toBe(true) + expect( + await worker.evaluate( + () => + (globalThis as typeof globalThis & { createdWindowCount: number }) + .createdWindowCount, + ), + ).toBe(0) + } + } + }) +} diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 2ff12311d..308026a95 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -1,5 +1,6 @@ { "manifest_version": 3, + "minimum_chrome_version": "116", "name": "__MSG_extName__", "description": "__MSG_extDescription__", "version": "1.3.2", @@ -51,6 +52,7 @@ "contextMenus", "declarativeNetRequest", "clipboardRead", + "offscreen", "system.display", "sidePanel" ], diff --git a/packages/extension/src/__tests__/service_worker.test.ts b/packages/extension/src/__tests__/service_worker.test.ts index dce53350e..f2f330d4c 100644 --- a/packages/extension/src/__tests__/service_worker.test.ts +++ b/packages/extension/src/__tests__/service_worker.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { enhancedSettings } from "@/services/settings/enhancedSettings" import { Settings } from "@/services/settings/settings" import { ServiceWorkerCommand } from "@/services/ipc" -import { NEW_HUB_URL, VERSION } from "@/const" +import { NEW_HUB_URL, VERSION, SHORTCUT_NO_SELECTION_BEHAVIOR } from "@/const" // Imported before any vi.doMock, so this is the real event name table. import { ANALYTICS_EVENTS } from "@/services/analytics" @@ -276,33 +276,44 @@ describe("Service Worker Migration", () => { ) }) - it("MG-01-d: should call enhancedSettings.get() in onCommand function and pass correct command to action/serviceWorker.execute", async () => { - // Setup test data - const testShortcutId = "test-shortcut-cmd" - const testCommandId = "test-command-123" - const initialSettings = createTestSettings() + const setupOnCommand = async ({ + tab, + tabSelection, + noSelectionBehavior = SHORTCUT_NO_SELECTION_BEHAVIOR.DO_NOTHING, + chromeFocused = true, + executeActionResult = new Error("Tab execution failed"), + }: { + tab: { id: number; url: string; windowId: number } + tabSelection?: unknown + noSelectionBehavior?: SHORTCUT_NO_SELECTION_BEHAVIOR + chromeFocused?: boolean + executeActionResult?: unknown + }) => { + const initialSettings = createTestSettings() as any + initialSettings.shortcuts.shortcuts[0].noSelectionBehavior = + noSelectionBehavior + mockEnhancedSettings.get.mockResolvedValue(initialSettings) - mockEnhancedSettings.get.mockResolvedValue(initialSettings as any) + vi.doMock("@/services/chrome", () => ({ + getCurrentTab: vi.fn().mockResolvedValue(tab), + closeWindow: vi.fn(), + windowExists: vi.fn(), + })) - // Mock chrome.tabs.query to return a valid tab - ;(chrome.tabs.query as any).mockResolvedValue([ - { id: 1, url: "https://example.com", windowId: 1 }, - ]) + ;(chrome.windows as any).getLastFocused = vi + .fn() + .mockResolvedValue({ id: tab.windowId, focused: chromeFocused }) // Mock the current window used to center the popup ;(chrome.windows as any).getCurrent = vi .fn() .mockResolvedValue({ left: 1920, top: 0, width: 1600, height: 1000 }) - // Mock Storage.get for selection text - const mockStorageGet = vi.fn().mockResolvedValue("test selection text") vi.doMock("@/services/storage", () => ({ Storage: { - get: mockStorageGet, - }, - SESSION_STORAGE_KEY: { - SELECTION_TEXT: "selectionText", + get: vi.fn(), }, + SESSION_STORAGE_KEY: {}, LOCAL_STORAGE_KEY: { CLIENT_ID: "clientId", }, @@ -314,10 +325,10 @@ describe("Service Worker Migration", () => { execute: mockExecute, })) - // Mock Ipc.sendTab to simulate tab execution failure (to force service worker execution) - const mockIpcSendTab = vi - .fn() - .mockResolvedValue(new Error("Tab execution failed")) + // By default executeAction fails, to force service worker execution. + const mockIpcSendTab = vi.fn(async (_tabId: number, command: string) => + command === "getSelection" ? tabSelection : executeActionResult, + ) vi.doMock("@/services/ipc", () => ({ Ipc: { sendTab: mockIpcSendTab, @@ -329,6 +340,7 @@ describe("Service Worker Migration", () => { }, TabCommand: { executeAction: "executeAction", + getSelection: "getSelection", }, })) @@ -343,7 +355,23 @@ describe("Service Worker Migration", () => { .calls expect(listenerCalls.length).toBeGreaterThan(0) - const commandListener = listenerCalls[0][0] + const commandListener = listenerCalls[listenerCalls.length - 1][0] + return { commandListener, mockExecute, mockIpcSendTab } + } + + const runShortcut = async (commandListener: (name: string) => unknown) => { + await commandListener("test-shortcut-cmd") + await new Promise((resolve) => setTimeout(resolve, 10)) + } + + it("MG-01-d: should call enhancedSettings.get() in onCommand function and pass correct command to action/serviceWorker.execute", async () => { + const testShortcutId = "test-shortcut-cmd" + const testCommandId = "test-command-123" + + const { commandListener, mockExecute } = await setupOnCommand({ + tab: { id: 1, url: "https://example.com", windowId: 1 }, + tabSelection: "test selection text", + }) // Call the listener with our test shortcut command await commandListener(testShortcutId) @@ -351,7 +379,6 @@ describe("Service Worker Migration", () => { // Wait for async operations await new Promise((resolve) => setTimeout(resolve, 10)) - // This should fail initially because the function still uses Settings.get() instead of enhancedSettings.get() expect(mockEnhancedSettings.get).toHaveBeenCalled() // Verify that the execute function was called with the correct command @@ -372,11 +399,106 @@ describe("Service Worker Migration", () => { position: { x: 600, y: 350 }, selectionText: "test selection text", target: null, - allowClipboardFallback: false, + selectionFromClipboard: false, pageUrl: "https://example.com", }), ) }) + + it("MG-01-e: should not ask a chrome:// tab for its selection and fall back to the clipboard", async () => { + const { commandListener, mockExecute, mockIpcSendTab } = + await setupOnCommand({ + tab: { id: 2, url: "chrome://settings/", windowId: 1 }, + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD, + }) + + await runShortcut(commandListener) + + expect(mockIpcSendTab).not.toHaveBeenCalled() + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + selectionText: "", + selectionFromClipboard: true, + pageUrl: "chrome://settings/", + }), + ) + }) + + it("MG-01-f: should do nothing when the tab has no selection and noSelectionBehavior is DO_NOTHING", async () => { + const { commandListener, mockExecute, mockIpcSendTab } = + await setupOnCommand({ + tab: { id: 1, url: "https://example.com", windowId: 1 }, + tabSelection: "", + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.DO_NOTHING, + }) + + await runShortcut(commandListener) + + expect(mockIpcSendTab).not.toHaveBeenCalledWith( + 1, + "executeAction", + expect.anything(), + ) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it("MG-01-g: should pass the selection of the tab to the content script", async () => { + const { commandListener, mockExecute, mockIpcSendTab } = + await setupOnCommand({ + tab: { id: 1, url: "https://example.com", windowId: 1 }, + tabSelection: "selected", + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD, + executeActionResult: true, + }) + + await runShortcut(commandListener) + + expect(mockIpcSendTab).toHaveBeenCalledWith(1, "getSelection") + expect(mockIpcSendTab).toHaveBeenCalledWith(1, "executeAction", { + command: expect.objectContaining({ id: "test-command-123" }), + selectionText: "selected", + selectionFromClipboard: false, + }) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it("MG-01-h: should treat the selection as empty when the tab doesn't respond", async () => { + // e.g. the content script isn't injected yet. + const { commandListener, mockExecute } = await setupOnCommand({ + tab: { id: 1, url: "https://example.com", windowId: 1 }, + tabSelection: undefined, + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD, + }) + + await runShortcut(commandListener) + + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + selectionText: "", + selectionFromClipboard: true, + }), + ) + }) + + it("MG-01-i: should treat the selection as empty when another app has focus", async () => { + // e.g. a global shortcut pressed right after copying text in another app. + const { commandListener, mockIpcSendTab } = await setupOnCommand({ + tab: { id: 1, url: "https://example.com", windowId: 1 }, + tabSelection: "selected", + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD, + chromeFocused: false, + executeActionResult: true, + }) + + await runShortcut(commandListener) + + expect(mockIpcSendTab).not.toHaveBeenCalledWith(1, "getSelection") + expect(mockIpcSendTab).toHaveBeenCalledWith(1, "executeAction", { + command: expect.objectContaining({ id: "test-command-123" }), + selectionText: "", + selectionFromClipboard: true, + }) + }) }) describe("Popup Auto-Close Delay", () => { @@ -424,9 +546,7 @@ describe("Popup Auto-Close Delay", () => { Storage: { set: mockStorageSet, }, - SESSION_STORAGE_KEY: { - SELECTION_TEXT: "selectionText", - }, + SESSION_STORAGE_KEY: {}, })) // Clear module cache and re-import @@ -487,9 +607,7 @@ describe("Popup Auto-Close Delay", () => { Storage: { set: mockStorageSet, }, - SESSION_STORAGE_KEY: { - SELECTION_TEXT: "selectionText", - }, + SESSION_STORAGE_KEY: {}, })) // Clear module cache and re-import @@ -553,9 +671,7 @@ describe("Popup Auto-Close Delay", () => { Storage: { set: mockStorageSet, }, - SESSION_STORAGE_KEY: { - SELECTION_TEXT: "selectionText", - }, + SESSION_STORAGE_KEY: {}, })) // Clear module cache and re-import diff --git a/packages/extension/src/action/__tests__/aiPrompt.test.ts b/packages/extension/src/action/__tests__/aiPrompt.test.ts index 405ae32c1..9afb07b08 100644 --- a/packages/extension/src/action/__tests__/aiPrompt.test.ts +++ b/packages/extension/src/action/__tests__/aiPrompt.test.ts @@ -13,6 +13,7 @@ import { SelectorType, } from "@/const" import { INSERT, toInsertTemplate } from "@/services/pageAction" +import { needsClipboard } from "@/lib/utils" import type { AiService } from "@/types" vi.mock("@/services/ipc", () => ({ @@ -121,6 +122,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -151,6 +153,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -173,6 +176,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -201,6 +205,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -234,6 +239,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -289,6 +295,7 @@ describe("AiPrompt.execute", () => { try { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -320,6 +327,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -355,6 +363,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -382,6 +391,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: baseCommand as any, position: { x: 0, y: 0 }, }) @@ -396,6 +406,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: baseCommand as any, position: { x: 0, y: 0 }, }) @@ -410,6 +421,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -446,6 +458,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -470,6 +483,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: baseCommand as any, position: { x: 0, y: 0 }, }) @@ -483,6 +497,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello world", + selectionFromClipboard: false, command: baseCommand as any, // prompt: "Explain: {{SelectedText}}" position: { x: 0, y: 0 }, }) @@ -496,6 +511,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -513,45 +529,45 @@ describe("AiPrompt.execute", () => { expect(sentArgs.url.searchUrl).toBe("https://chatgpt.com/?prompt=%s") // {{SelectedText}} is expanded, {{Clipboard}} is left for the service worker expect(sentArgs.url.selectionText).toBe("{{Clipboard}} + hello") - expect(sentArgs.url.useClipboard).toBe(true) + expect(needsClipboard(sentArgs.url)).toBe(true) expect(sentArgs.url.clipboardTemplate).toEqual({ urlToMarkdown: false }) }) - it("AP-08b: should leave {{SelectedText}} for the clipboard when there is no selection and allowClipboardFallback is true", async () => { + it("AP-08b: should leave {{SelectedText}} for the clipboard when there is no selection and selectionFromClipboard is true", async () => { vi.mocked(findAiService).mockResolvedValue(makeQueryService()) await AiPrompt.execute({ selectionText: "", command: baseCommand as any, // prompt: "Explain: {{SelectedText}}" position: { x: 0, y: 0 }, - allowClipboardFallback: true, + selectionFromClipboard: true, }) const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any const stepTypes = sentArgs.steps.map((s: any) => s.param.type) expect(stepTypes).not.toContain(PAGE_ACTION_EVENT.input) expect(sentArgs.url.selectionText).toBe("Explain: {{SelectedText}}") - expect(sentArgs.url.useClipboard).toBe(true) + expect(needsClipboard(sentArgs.url)).toBe(true) expect(sentArgs.url.clipboardTemplate).toEqual({ urlToMarkdown: false }) }) - it("AP-08c: should expand the selection and not read the clipboard when a selection exists even if allowClipboardFallback is true", async () => { + it("AP-08c: should expand the selection and not read the clipboard when selectionFromClipboard is false", async () => { vi.mocked(findAiService).mockResolvedValue(makeQueryService()) await AiPrompt.execute({ selectionText: "hello", command: baseCommand as any, position: { x: 0, y: 0 }, - allowClipboardFallback: true, + selectionFromClipboard: false, }) const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any expect(sentArgs.url.selectionText).toBe("Explain: hello") - expect(sentArgs.url.useClipboard).toBe(false) + expect(needsClipboard(sentArgs.url)).toBe(false) expect(sentArgs.url.clipboardTemplate).toBeUndefined() }) - it("AP-08d: should not read the clipboard when allowClipboardFallback is true but the prompt has no {{SelectedText}}", async () => { + it("AP-08d: should not read the clipboard when selectionFromClipboard is true but the prompt has no {{SelectedText}}", async () => { vi.mocked(findAiService).mockResolvedValue(makeQueryService()) await AiPrompt.execute({ @@ -564,13 +580,13 @@ describe("AiPrompt.execute", () => { }, } as any, position: { x: 0, y: 0 }, - allowClipboardFallback: true, + selectionFromClipboard: true, pageUrl: "https://example.com", }) const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any expect(sentArgs.url.selectionText).toBe("Summarize https://example.com") - expect(sentArgs.url.useClipboard).toBe(false) + expect(needsClipboard(sentArgs.url)).toBe(false) }) it("AP-08e: should pass urlToMarkdown to the service worker when the clipboard is needed", async () => { @@ -586,7 +602,7 @@ describe("AiPrompt.execute", () => { }, } as any, position: { x: 0, y: 0 }, - allowClipboardFallback: true, + selectionFromClipboard: true, }) const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any @@ -595,7 +611,7 @@ describe("AiPrompt.execute", () => { }) describe("DOM input approach with clipboard", () => { - it("AP-08f: should request the clipboard when there is no selection and allowClipboardFallback is true", async () => { + it("AP-08f: should request the clipboard when there is no selection and selectionFromClipboard is true", async () => { vi.mocked(findAiService).mockResolvedValue(makeDomService()) await AiPrompt.execute({ @@ -608,13 +624,14 @@ describe("AiPrompt.execute", () => { }, } as any, position: { x: 0, y: 0 }, - allowClipboardFallback: true, + selectionFromClipboard: true, }) const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any const stepTypes = sentArgs.steps.map((s: any) => s.param.type) expect(stepTypes).toContain(PAGE_ACTION_EVENT.input) - expect(sentArgs.url.useClipboard).toBe(true) + expect(sentArgs.url.selectionFromClipboard).toBe(true) + expect(sentArgs.url.clipboardForSteps).toBe(false) expect(sentArgs.url.clipboardTemplate).toBeUndefined() }) }) @@ -625,6 +642,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -646,6 +664,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -666,6 +685,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -683,7 +703,8 @@ describe("AiPrompt.execute", () => { const stepTypes = storedPending.steps.map((s: any) => s.param.type) expect(stepTypes).toContain(PAGE_ACTION_EVENT.input) expect(storedPending.url).toBe("https://chatgpt.com") - expect(storedPending.useClipboard).toBe(true) + expect(storedPending.clipboardForSteps).toBe(true) + expect(storedPending.selectionFromClipboard).toBe(false) }) }) @@ -693,6 +714,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "https://example.com/page", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -715,6 +737,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "hello", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { @@ -740,6 +763,7 @@ describe("AiPrompt.execute", () => { await AiPrompt.execute({ selectionText: "https://example.com/page", + selectionFromClipboard: false, command: { ...baseCommand, aiPromptOption: { diff --git a/packages/extension/src/action/__tests__/pageAction.test.ts b/packages/extension/src/action/__tests__/pageAction.test.ts index 8ec043888..45b1cc21c 100644 --- a/packages/extension/src/action/__tests__/pageAction.test.ts +++ b/packages/extension/src/action/__tests__/pageAction.test.ts @@ -59,6 +59,7 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: baseCommand as any, selectionText: "Selected content", + selectionFromClipboard: false, position: { x: 50, y: 50 }, }) @@ -73,7 +74,7 @@ describe("PageAction.execute", () => { url: expect.objectContaining({ searchUrl: "https://ai.example.com", selectionText: "Selected content", - useClipboard: false, + clipboardForSteps: false, }), }), ) @@ -93,6 +94,7 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: commandWithClipboardVariable as any, selectionText: "", + selectionFromClipboard: false, position: { x: 50, y: 50 }, }) @@ -100,7 +102,7 @@ describe("PageAction.execute", () => { ServiceWorkerCommand.openAndRunPageAction, expect.objectContaining({ url: expect.objectContaining({ - useClipboard: true, + clipboardForSteps: true, }), }), ) @@ -131,6 +133,7 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: commandWithoutVariableStep as any, selectionText: "hello", + selectionFromClipboard: false, position: { x: 50, y: 50 }, }) @@ -138,7 +141,7 @@ describe("PageAction.execute", () => { ServiceWorkerCommand.openAndRunPageAction, expect.objectContaining({ url: expect.objectContaining({ - useClipboard: false, + clipboardForSteps: false, }), }), ) @@ -170,6 +173,7 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: legacyCommand as any, selectionText: "sample", + selectionFromClipboard: false, position: { x: 10, y: 10 }, }) @@ -181,7 +185,7 @@ describe("PageAction.execute", () => { url: expect.objectContaining({ searchUrl: "https://example.com", selectionText: "sample", - useClipboard: false, + clipboardForSteps: false, }), }), ) @@ -199,6 +203,7 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: sidePanelCommand as any, selectionText: "side panel content", + selectionFromClipboard: false, position: { x: 50, y: 50 }, pageUrl: "https://origin.example.com", }) @@ -211,7 +216,7 @@ describe("PageAction.execute", () => { selectedText: "side panel content", srcUrl: "https://origin.example.com", clipboardText: "", - useClipboard: false, + clipboardForSteps: false, userVariables: [ { name: "Prompt", value: "Summarize: {{SelectedText}}" }, ], @@ -239,6 +244,7 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: sidePanelCommand as any, selectionText: "context menu invocation", + selectionFromClipboard: false, position: null, }) @@ -268,13 +274,14 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: sidePanelCommand as any, selectionText: "", + selectionFromClipboard: false, position: { x: 50, y: 50 }, }) expect(Storage.set).toHaveBeenCalledWith( SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING, expect.objectContaining({ - useClipboard: true, + clipboardForSteps: true, userVariables: [ { name: "Prompt", value: "Translate clipboard: {{Clipboard}}" }, ], @@ -305,6 +312,7 @@ describe("PageAction.execute", () => { await PageAction.execute({ command: sidePanelLegacyCommand as any, selectionText: "legacy text", + selectionFromClipboard: false, position: null, }) diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts index 9275cd13d..ef3b0d43c 100644 --- a/packages/extension/src/action/aiPrompt.ts +++ b/packages/extension/src/action/aiPrompt.ts @@ -6,7 +6,6 @@ import { import { getWindowPosition } from "@/services/screen" import { isValidString, - isEmpty, generateRandomID, safeInterpolate, toUrl, @@ -24,7 +23,7 @@ import { } from "@/const" import { resolvePopupSize } from "@/services/option/defaultSettings" import type { - ExecuteCommandParams, + ActionExecuteParams, PageActionStep, UrlParam, AiService, @@ -80,7 +79,8 @@ const createEndStep = (): PageActionStep => type PromptRequirements = { needClipboard: boolean - selectionFromClipboard: boolean + clipboardInPrompt: boolean + selectedTextFromClipboard: boolean needPageHtml: boolean pageHtml: string | undefined needSelectionHtml: boolean @@ -94,20 +94,17 @@ type PromptRequirements = { const analyzePromptRequirements = ( aiPromptOption: AiPromptOption, service: AiService, - selectionText: string, - allowClipboardFallback: boolean, + selectionFromClipboard: boolean, ): PromptRequirements => { - // {{SelectedText}} falls back to the clipboard text when the command is run - // without a selection and the caller allows it (e.g. from a shortcut key). - const selectionFromClipboard = - allowClipboardFallback && - isEmpty(selectionText) && + const selectedTextFromClipboard = + selectionFromClipboard && aiPromptOption.prompt.includes(toInsertTemplate(INSERT.SELECTED_TEXT)) // Checks if the prompt requires clipboard data - const needClipboard = - aiPromptOption.prompt.includes(toInsertTemplate(INSERT.CLIPBOARD)) || - selectionFromClipboard + const clipboardInPrompt = aiPromptOption.prompt.includes( + toInsertTemplate(INSERT.CLIPBOARD), + ) + const needClipboard = clipboardInPrompt || selectedTextFromClipboard // 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 @@ -139,7 +136,8 @@ const analyzePromptRequirements = ( return { needClipboard, - selectionFromClipboard, + clipboardInPrompt, + selectedTextFromClipboard, needPageHtml, pageHtml, needSelectionHtml, @@ -225,7 +223,7 @@ const buildQueryUrlSteps = ( selectionText: string, pageUrl: string | undefined, needClipboard: boolean, - selectionFromClipboard: boolean, + selectedTextFromClipboard: boolean, ): { steps: PageActionStep[]; urlParam: UrlParam; serviceUrl: string } => { // Pre-expand the prompt template with synchronously available variables. // INSERT.CLIPBOARD (and INSERT.SELECTED_TEXT when it falls back to the @@ -236,7 +234,7 @@ const buildQueryUrlSteps = ( [InsertSymbol[INSERT.URL]]: pageUrl ?? "", [InsertSymbol[INSERT.LANG]]: getUILanguage(), } - if (!selectionFromClipboard) { + if (!selectedTextFromClipboard) { variables[InsertSymbol[INSERT.SELECTED_TEXT]] = selectionText } const expandedPrompt = safeInterpolate(aiPromptOption.prompt, variables) @@ -248,7 +246,6 @@ const buildQueryUrlSteps = ( const urlParam: UrlParam = { searchUrl: service.queryUrl!, selectionText: finalPrompt, - useClipboard: needClipboard, clipboardTemplate: needClipboard ? { urlToMarkdown: service.urlToMarkdown ?? false } : undefined, @@ -329,7 +326,8 @@ const buildDomInputSteps = ( service: AiService, aiPromptOption: AiPromptOption, selectionText: string, - needClipboard: boolean, + clipboardInPrompt: boolean, + selectedTextFromClipboard: boolean, needPageHtml: boolean, needSelectionHtml: boolean, needFilePaste: boolean, @@ -341,7 +339,8 @@ const buildDomInputSteps = ( const urlParam: UrlParam = { searchUrl: service.url, selectionText, - useClipboard: needClipboard, + selectionFromClipboard: selectedTextFromClipboard, + clipboardForSteps: clipboardInPrompt, } // When HTML placeholders are present, build filePaste steps that upload @@ -409,7 +408,8 @@ const runSidePanelAction = async (params: { steps: PageActionStep[] selectionText: string pageUrl: string | undefined - needClipboard: boolean + clipboardInPrompt: boolean + selectedTextFromClipboard: boolean pageHtml: string | undefined selectionHtml: string | undefined }): Promise => { @@ -418,7 +418,8 @@ const runSidePanelAction = async (params: { steps, selectionText, pageUrl, - needClipboard, + clipboardInPrompt, + selectedTextFromClipboard, pageHtml, selectionHtml, } = params @@ -430,7 +431,8 @@ const runSidePanelAction = async (params: { srcUrl: pageUrl ?? "", clipboardText: "", // Always false with the query URL approach: see analyzePromptRequirements. - useClipboard: needClipboard, + selectionFromClipboard: selectedTextFromClipboard, + clipboardForSteps: clipboardInPrompt, pageHtml, selectionHtml, } @@ -468,9 +470,9 @@ export const AiPrompt = { command, position, useSecondary, - allowClipboardFallback, + selectionFromClipboard, pageUrl, - }: ExecuteCommandParams) { + }: ActionExecuteParams) { if (!isAiPromptType(command)) { console.error("command is not for AiPrompt.") return @@ -491,7 +493,8 @@ export const AiPrompt = { const { needClipboard, - selectionFromClipboard, + clipboardInPrompt, + selectedTextFromClipboard, needPageHtml, pageHtml, needSelectionHtml, @@ -501,8 +504,7 @@ export const AiPrompt = { } = analyzePromptRequirements( aiPromptOption, service, - selectionText, - allowClipboardFallback ?? false, + selectionFromClipboard, ) const { steps, urlParam, serviceUrl } = useQueryUrl @@ -512,13 +514,14 @@ export const AiPrompt = { selectionText, pageUrl, needClipboard, - selectionFromClipboard, + selectedTextFromClipboard, ) : buildDomInputSteps( service, aiPromptOption, selectionText, - needClipboard, + clipboardInPrompt, + selectedTextFromClipboard, needPageHtml, needSelectionHtml, needFilePaste, @@ -533,7 +536,8 @@ export const AiPrompt = { steps, selectionText, pageUrl, - needClipboard, + clipboardInPrompt, + selectedTextFromClipboard, pageHtml, selectionHtml, }) diff --git a/packages/extension/src/action/backgroundTab.ts b/packages/extension/src/action/backgroundTab.ts index a60a287dd..c02e1a2d3 100644 --- a/packages/extension/src/action/backgroundTab.ts +++ b/packages/extension/src/action/backgroundTab.ts @@ -1,16 +1,16 @@ import { Ipc, ServiceWorkerCommand } from "@/services/ipc" import { isValidString } from "@/lib/utils" import { SPACE_ENCODING } from "@/const" -import type { ExecuteCommandParams } from "@/types" +import type { ActionExecuteParams } from "@/types" import type { OpenTabProps } from "@/services/chrome" export const BackgroundTab = { async execute({ selectionText, command, - allowClipboardFallback, + selectionFromClipboard, pageUrl, - }: ExecuteCommandParams) { + }: ActionExecuteParams) { if (!isValidString(command.searchUrl)) { console.error("searchUrl is not valid.") return @@ -21,7 +21,7 @@ export const BackgroundTab = { searchUrl: command.searchUrl, spaceEncoding: command.spaceEncoding ?? SPACE_ENCODING.PLUS, selectionText, - useClipboard: allowClipboardFallback ?? false, + selectionFromClipboard, pageUrl: pageUrl ?? "", }, active: false, diff --git a/packages/extension/src/action/executor.ts b/packages/extension/src/action/executor.ts index ba76c6a6f..2932c73bd 100644 --- a/packages/extension/src/action/executor.ts +++ b/packages/extension/src/action/executor.ts @@ -14,7 +14,7 @@ export async function executeAction({ selectionText, target, useSecondary = false, - allowClipboardFallback = false, + selectionFromClipboard = false, changeState, pageUrl, }: ExecuteCommandParams & { actions: Record }) { @@ -32,7 +32,7 @@ export async function executeAction({ command, position, useSecondary, - allowClipboardFallback, + selectionFromClipboard, changeState: changeState ?? (() => {}), target: target ?? null, pageUrl, diff --git a/packages/extension/src/action/index.ts b/packages/extension/src/action/index.ts index 4eacc9ca2..77239338d 100644 --- a/packages/extension/src/action/index.ts +++ b/packages/extension/src/action/index.ts @@ -37,7 +37,7 @@ export async function execute({ selectionText, target, useSecondary = false, - allowClipboardFallback = false, + selectionFromClipboard = false, changeState, }: ExecuteCommandParams) { return executeAction({ @@ -46,7 +46,7 @@ export async function execute({ selectionText, target, useSecondary, - allowClipboardFallback, + selectionFromClipboard, changeState, // This module only ever runs in the content script's page context, so // location.href here correctly reflects the visited page. diff --git a/packages/extension/src/action/pageAction.ts b/packages/extension/src/action/pageAction.ts index a4e598e4e..fce40cf4b 100644 --- a/packages/extension/src/action/pageAction.ts +++ b/packages/extension/src/action/pageAction.ts @@ -9,7 +9,7 @@ import { getWindowPosition } from "@/services/screen" import { isValidString, isPageActionCommand } from "@/lib/utils" import { PAGE_ACTION_OPEN_MODE, PAGE_ACTION_EVENT } from "@/const" import { resolvePopupSize } from "@/services/option/defaultSettings" -import type { ExecuteCommandParams, UrlParam } from "@/types" +import type { ActionExecuteParams, UrlParam } from "@/types" import type { OpenAndRunProps } from "@/services/pageAction/serviceWorker" import { INSERT, templateReferencesInsert } from "@/services/pageAction" @@ -23,10 +23,10 @@ export const PageAction = { command, position, useSecondary, - allowClipboardFallback, + selectionFromClipboard, userVariables, pageUrl, - }: ExecuteCommandParams & PageActionParams) { + }: ActionExecuteParams & PageActionParams) { if (!isPageActionCommand(command)) { console.error("command is not for PageAction.") return @@ -41,7 +41,7 @@ export const PageAction = { userVariables ?? command.pageActionOption.userVariables // Checks if any step needs clipboard data, directly or through a user variable - const needClipboard = command.pageActionOption.steps.some( + const clipboardForSteps = command.pageActionOption.steps.some( (step) => step.param.type === PAGE_ACTION_EVENT.input && templateReferencesInsert( @@ -63,7 +63,8 @@ export const PageAction = { selectedText: selectionText, srcUrl: pageUrl ?? "", clipboardText: "", - useClipboard: needClipboard || (allowClipboardFallback ?? false), + selectionFromClipboard, + clipboardForSteps, userVariables: effectiveUserVariables, } try { @@ -89,7 +90,8 @@ export const PageAction = { const url: UrlParam = { searchUrl: command.pageActionOption.startUrl, selectionText, - useClipboard: needClipboard || (allowClipboardFallback ?? false), + selectionFromClipboard, + clipboardForSteps, pageUrl: pageUrl ?? "", } diff --git a/packages/extension/src/action/popup.ts b/packages/extension/src/action/popup.ts index 771d4b4ad..3f2f79352 100644 --- a/packages/extension/src/action/popup.ts +++ b/packages/extension/src/action/popup.ts @@ -3,7 +3,7 @@ import { isValidString } from "@/lib/utils" import { getWindowPosition } from "@/services/screen" import { POPUP_TYPE, SPACE_ENCODING } from "@/const" import { resolvePopupSize } from "@/services/option/defaultSettings" -import type { ExecuteCommandParams } from "@/types" +import type { ActionExecuteParams } from "@/types" import type { OpenPopupProps } from "@/services/chrome" export const Popup = { @@ -11,9 +11,9 @@ export const Popup = { selectionText, command, position, - allowClipboardFallback, + selectionFromClipboard, pageUrl, - }: ExecuteCommandParams) { + }: ActionExecuteParams) { if (!isValidString(command.searchUrl)) { console.error("searchUrl is not valid.") return @@ -31,7 +31,7 @@ export const Popup = { searchUrl: command.searchUrl, spaceEncoding: command.spaceEncoding ?? SPACE_ENCODING.PLUS, selectionText, - useClipboard: allowClipboardFallback ?? false, + selectionFromClipboard, pageUrl: pageUrl ?? "", }, top: Math.floor(windowPosition.top + position.y), diff --git a/packages/extension/src/action/serviceWorker.ts b/packages/extension/src/action/serviceWorker.ts index 1b7a4109b..12fc86e43 100644 --- a/packages/extension/src/action/serviceWorker.ts +++ b/packages/extension/src/action/serviceWorker.ts @@ -25,7 +25,7 @@ export async function execute({ selectionText, target, useSecondary = false, - allowClipboardFallback = false, + selectionFromClipboard = false, changeState, pageUrl, }: ExecuteCommandParams) { @@ -35,7 +35,7 @@ export async function execute({ selectionText, target, useSecondary, - allowClipboardFallback, + selectionFromClipboard, changeState, pageUrl, actions: actionsForServiceWorker, diff --git a/packages/extension/src/action/sidePanel.ts b/packages/extension/src/action/sidePanel.ts index c9288220f..c76337e7b 100644 --- a/packages/extension/src/action/sidePanel.ts +++ b/packages/extension/src/action/sidePanel.ts @@ -1,6 +1,6 @@ -import { isValidString, toUrl, isEmpty } from "@/lib/utils" +import { isValidString, toUrl } from "@/lib/utils" import { SPACE_ENCODING } from "@/const" -import type { ExecuteCommandParams, ShowToastParam } from "@/types" +import type { ActionExecuteParams, ShowToastParam } from "@/types" import type { OpenSidePanelProps } from "@/services/chrome" import { Ipc, ServiceWorkerCommand, TabCommand } from "@/services/ipc" import { t } from "@/services/i18n" @@ -9,9 +9,9 @@ export const SidePanel = { async execute({ selectionText, command, - allowClipboardFallback, + selectionFromClipboard, pageUrl, - }: ExecuteCommandParams) { + }: ActionExecuteParams) { if (!isValidString(command.searchUrl)) { console.error("searchUrl is not valid.") return @@ -19,7 +19,7 @@ export const SidePanel = { // Read clipboard text for interpolation, but don't block execution if it fails. let clipboardText: string = "" try { - if (allowClipboardFallback && isEmpty(selectionText)) { + if (selectionFromClipboard) { clipboardText = await navigator.clipboard.readText() } } catch (e) { @@ -39,7 +39,7 @@ export const SidePanel = { searchUrl: command.searchUrl, spaceEncoding: command.spaceEncoding ?? SPACE_ENCODING.PLUS, selectionText, - useClipboard: allowClipboardFallback ?? false, + selectionFromClipboard, pageUrl: pageUrl ?? "", }, clipboardText, diff --git a/packages/extension/src/action/tab.ts b/packages/extension/src/action/tab.ts index 23bb6a799..16aae56dd 100644 --- a/packages/extension/src/action/tab.ts +++ b/packages/extension/src/action/tab.ts @@ -1,16 +1,16 @@ import { Ipc, ServiceWorkerCommand } from "@/services/ipc" import { isValidString } from "@/lib/utils" import { SPACE_ENCODING } from "@/const" -import type { ExecuteCommandParams } from "@/types" +import type { ActionExecuteParams } from "@/types" import type { OpenTabProps } from "@/services/chrome" export const Tab = { async execute({ selectionText, command, - allowClipboardFallback, + selectionFromClipboard, pageUrl, - }: ExecuteCommandParams) { + }: ActionExecuteParams) { if (!isValidString(command.searchUrl)) { console.error("searchUrl is not valid.") return @@ -21,7 +21,7 @@ export const Tab = { searchUrl: command.searchUrl, spaceEncoding: command.spaceEncoding ?? SPACE_ENCODING.PLUS, selectionText, - useClipboard: allowClipboardFallback ?? false, + selectionFromClipboard, pageUrl: pageUrl ?? "", }, active: true, diff --git a/packages/extension/src/action/window.ts b/packages/extension/src/action/window.ts index 75c20b8a5..01fe453e2 100644 --- a/packages/extension/src/action/window.ts +++ b/packages/extension/src/action/window.ts @@ -4,16 +4,16 @@ import { getWindowPosition } from "@/services/screen" import { POPUP_TYPE, SPACE_ENCODING } from "@/const" import { resolvePopupSize } from "@/services/option/defaultSettings" import type { OpenPopupProps } from "@/services/chrome" -import type { ExecuteCommandParams } from "@/types" +import type { ActionExecuteParams } from "@/types" export const Window = { async execute({ selectionText, command, position, - allowClipboardFallback, + selectionFromClipboard, pageUrl, - }: ExecuteCommandParams) { + }: ActionExecuteParams) { if (!isValidString(command.searchUrl)) { console.error("searchUrl is not valid.") return @@ -31,7 +31,7 @@ export const Window = { searchUrl: command.searchUrl, spaceEncoding: command.spaceEncoding ?? SPACE_ENCODING.PLUS, selectionText, - useClipboard: allowClipboardFallback ?? false, + selectionFromClipboard, pageUrl: pageUrl ?? "", }, top: Math.floor(windowPosition.top + position.y), diff --git a/packages/extension/src/clipboard.html b/packages/extension/src/clipboard.html deleted file mode 100644 index 1a4dd1449..000000000 --- a/packages/extension/src/clipboard.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Selection Command - - - - - diff --git a/packages/extension/src/clipboard.ts b/packages/extension/src/clipboard.ts deleted file mode 100644 index 4d1628e05..000000000 --- a/packages/extension/src/clipboard.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ClipboardResult, ServiceWorkerCommand } from "@/services/ipc" - -/** - * Read text from clipboard with retry mechanism - * @param maxRetries Maximum number of retry attempts (default: 3) - * @param delayMs Delay between retries in milliseconds (default: 100) - * @returns Promise Clipboard content or empty string if all attempts fail - */ -const readClipboardWithRetry = async ( - maxRetries = 3, - delayMs = 100, -): Promise => { - for (let i = 0; i < maxRetries; i++) { - try { - return { data: await navigator.clipboard.readText() } - } catch (error) { - console.warn( - `Retry to read clipboard (attempt ${i + 1}/${maxRetries}):`, - error, - ) - if (i === maxRetries - 1) { - return { data: undefined, err: `Retry limit exceeded. [${maxRetries}]` } - } - await new Promise((resolve) => setTimeout(resolve, delayMs)) - } - } - return { data: undefined, err: "Out of retries." } -} - -const port = chrome.runtime.connect({ name: "clipboard" }) -port.onDisconnect.addListener(function (port) { - console.warn("onDisconnect", port.name) -}) - -readClipboardWithRetry() - .then((ret: ClipboardResult) => { - port.postMessage({ command: ServiceWorkerCommand.setClipboard, data: ret }) - }) - .catch((error: Error) => { - port.postMessage({ - command: ServiceWorkerCommand.setClipboard, - data: { data: undefined, err: error.message }, - }) - }) - .finally(() => { - port.disconnect() - }) diff --git a/packages/extension/src/components/menu/InvisibleItem.tsx b/packages/extension/src/components/menu/InvisibleItem.tsx index c9c1315f8..f4223fb9e 100644 --- a/packages/extension/src/components/menu/InvisibleItem.tsx +++ b/packages/extension/src/components/menu/InvisibleItem.tsx @@ -17,7 +17,7 @@ type InvisibleItemProps = { export function InvisibleItem(props: InvisibleItemProps): React.ReactNode { const { selectionText, target } = useSelectContext() - const { command, setCommand, allowClipboardFallback } = useContextMenu() + const { action, clearAction } = useContextMenu() const { itemState, result, executeCommand, clearResult } = useCommandExecutor() const elmRef = useRef(null) @@ -62,15 +62,17 @@ export function InvisibleItem(props: InvisibleItemProps): React.ReactNode { } }, [position]) - if (command != null) { + if (action != null) { executeCommand({ - command: command as SelectionCommand, + command: action.command as SelectionCommand, position, - selectionText, + // Prefer the selection decided by the sender, which ignores the + // selection when another app has focus. + selectionText: action.selectionText ?? selectionText, target, - allowClipboardFallback, + selectionFromClipboard: action.selectionFromClipboard, }) - setCommand(null) + clearAction() } const visible = result != null diff --git a/packages/extension/src/components/menu/__tests__/InvisibleItem.test.tsx b/packages/extension/src/components/menu/__tests__/InvisibleItem.test.tsx new file mode 100644 index 000000000..ab6a4b079 --- /dev/null +++ b/packages/extension/src/components/menu/__tests__/InvisibleItem.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render } from "@testing-library/react" +import { InvisibleItem } from "../InvisibleItem" +import { ExecState } from "@/const" +import type { executeActionProps } from "@/services/contextMenus" + +const mockExecuteCommand = vi.fn() +const mockClearAction = vi.fn() +let mockAction: executeActionProps | null = null + +vi.mock("@/hooks/useCommandExecutor", () => ({ + useCommandExecutor: () => ({ + itemState: { state: ExecState.NONE, message: "" }, + result: null, + executeCommand: mockExecuteCommand, + clearResult: vi.fn(), + }), +})) + +vi.mock("@/hooks/useSelectContext", () => ({ + useSelectContext: () => ({ + selectionText: "selection in tab", + target: null, + }), +})) + +vi.mock("@/hooks/useContextMenu", () => ({ + useContextMenu: () => ({ + action: mockAction, + clearAction: mockClearAction, + }), +})) + +const command = { + id: "cmd-1", + title: "Search", + searchUrl: "https://example.com/?q=%s", + openMode: "tab", +} as any + +describe("InvisibleItem", () => { + beforeEach(() => { + vi.clearAllMocks() + mockAction = null + }) + + it("II-01: 正常系: 送信元が決めた選択テキストとクリップボード利用指定で実行する", () => { + mockAction = { command, selectionText: "", selectionFromClipboard: true } + + render() + + expect(mockExecuteCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command, + selectionText: "", + selectionFromClipboard: true, + }), + ) + expect(mockClearAction).toHaveBeenCalled() + }) + + it("II-02: 正常系: 送信元が選択テキストを指定しない場合はタブの選択テキストで実行する", () => { + // e.g. the context menu + mockAction = { command } + + render() + + expect(mockExecuteCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command, + selectionText: "selection in tab", + }), + ) + }) + + it("II-03: 正常系: 実行要求がなければ何もしない", () => { + render() + + expect(mockExecuteCommand).not.toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx index 3f02e4774..507e55d0a 100644 --- a/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx +++ b/packages/extension/src/components/option/editor/SearchUrlAssistDialog.tsx @@ -87,7 +87,7 @@ export const SearchUrlAssistDialog = ({ command, position: { x: 0, y: 0 }, useSecondary: false, - allowClipboardFallback: false, + selectionFromClipboard: false, userVariables: [ { name: "search_keyword", value: data.searchKeyword }, { name: "search_result_url", value: data.searchResultUrl }, diff --git a/packages/extension/src/hooks/useCommandExecutor.ts b/packages/extension/src/hooks/useCommandExecutor.ts index 184d2473f..8a03c200c 100644 --- a/packages/extension/src/hooks/useCommandExecutor.ts +++ b/packages/extension/src/hooks/useCommandExecutor.ts @@ -14,7 +14,7 @@ type ExecuteCommandParams = { selectionText: string target: Element | null useSecondary?: boolean - allowClipboardFallback?: boolean + selectionFromClipboard?: boolean } export function useCommandExecutor() { @@ -33,7 +33,7 @@ export function useCommandExecutor() { selectionText, target, useSecondary = false, - allowClipboardFallback = false, + selectionFromClipboard = false, }: ExecuteCommandParams) => { if (itemState.state !== ExecState.NONE) { return @@ -45,7 +45,7 @@ export function useCommandExecutor() { selectionText, target, useSecondary, - allowClipboardFallback, + selectionFromClipboard, changeState: onChangeState, }) diff --git a/packages/extension/src/hooks/useContextMenu.ts b/packages/extension/src/hooks/useContextMenu.ts index 1b9f81d2e..bf0b9bad2 100644 --- a/packages/extension/src/hooks/useContextMenu.ts +++ b/packages/extension/src/hooks/useContextMenu.ts @@ -2,17 +2,13 @@ import { useState, useEffect } from "react" import { Ipc, TabCommand } from "@/services/ipc" import type { IpcCallback } from "@/services/ipc" import { executeActionProps } from "@/services/contextMenus" -import { Command } from "@/types" export function useContextMenu() { - const [command, setCommand] = useState(null) - const [allowClipboardFallback, setAllowClipboardFallback] = - useState(false) + const [action, setAction] = useState(null) useEffect(() => { Ipc.addListener(TabCommand.executeAction, ((param: executeActionProps) => { - setCommand(param.command) - setAllowClipboardFallback(param.allowClipboardFallback ?? false) + setAction(param) return false }) as IpcCallback) return () => { @@ -20,5 +16,7 @@ export function useContextMenu() { } }, []) - return { command, setCommand, allowClipboardFallback } + const clearAction = () => setAction(null) + + return { action, clearAction } } diff --git a/packages/extension/src/lib/__tests__/utils.test.ts b/packages/extension/src/lib/__tests__/utils.test.ts index caca88761..4bc29cf83 100644 --- a/packages/extension/src/lib/__tests__/utils.test.ts +++ b/packages/extension/src/lib/__tests__/utils.test.ts @@ -4,6 +4,7 @@ import { toUrl, matchesPageActionUrl, getCommandTargetUrl, + needsClipboard, } from "../utils" import { OPEN_MODE, PAGE_ACTION_OPEN_MODE, SPACE_ENCODING } from "@/const" import type { UrlParam } from "@/types" @@ -124,11 +125,11 @@ describe("toUrl", () => { expect(toUrl(param)).toBe("https://example.com/search?q=hello%2Fworld") }) - it("TU-06: uses clipboard text when useClipboard is true and selectionText is empty", () => { + it("TU-06: uses clipboard text when selectionFromClipboard is true", () => { const param: UrlParam = { searchUrl: "https://example.com/search?q=%s", selectionText: "", - useClipboard: true, + selectionFromClipboard: true, } const clipboardText = "clipboard content" expect(toUrl(param, clipboardText)).toBe( @@ -136,11 +137,10 @@ describe("toUrl", () => { ) }) - it("TU-07: prefers selectionText over clipboard when both are present", () => { + it("TU-07: uses selectionText when selectionFromClipboard is not set even if clipboard text is given", () => { const param: UrlParam = { searchUrl: "https://example.com/search?q=%s", selectionText: "selection", - useClipboard: true, } const clipboardText = "clipboard" expect(toUrl(param, clipboardText)).toBe( @@ -148,11 +148,11 @@ describe("toUrl", () => { ) }) - it("TU-08: uses empty string when useClipboard is true but clipboard is undefined", () => { + it("TU-08: uses empty string when selectionFromClipboard is true but clipboard is undefined", () => { const param: UrlParam = { searchUrl: "https://example.com/search?q=%s", selectionText: "", - useClipboard: true, + selectionFromClipboard: true, } expect(toUrl(param)).toBe("https://example.com/search?q=") }) @@ -161,7 +161,6 @@ describe("toUrl", () => { const param: UrlParam = { searchUrl: "https://example.com/?prompt=%s", selectionText: "Explain: {{SelectedText}} / {{Clipboard}}", - useClipboard: true, clipboardTemplate: {}, } expect(toUrl(param, "clip")).toBe( @@ -173,7 +172,6 @@ describe("toUrl", () => { const param: UrlParam = { searchUrl: "https://example.com/?prompt=%s", selectionText: "{{Unknown}} {{Clipboard}}", - useClipboard: true, clipboardTemplate: {}, } expect(toUrl(param, "clip")).toBe( @@ -185,7 +183,6 @@ describe("toUrl", () => { const param: UrlParam = { searchUrl: "https://example.com/?prompt=%s", selectionText: "Explain: {{SelectedText}}", - useClipboard: true, clipboardTemplate: {}, } expect(toUrl(param)).toBe("https://example.com/?prompt=Explain%3A+") @@ -196,7 +193,6 @@ describe("toUrl", () => { searchUrl: "https://example.com/?q=%s", selectionText: "[https://a.com](https://a.com) {{Clipboard}}", spaceEncoding: SPACE_ENCODING.PERCENT, - useClipboard: true, clipboardTemplate: { urlToMarkdown: true }, } // Already-converted links in the template are not converted twice. @@ -208,16 +204,14 @@ describe("toUrl", () => { ) }) - it("TU-08-e: ignores clipboardTemplate when useClipboard is false", () => { + it("TU-08-e: does not check selectionText again when selectionFromClipboard is true", () => { + // Whether to use the clipboard is decided by the caller only once. const param: UrlParam = { - searchUrl: "https://example.com/?prompt=%s", - selectionText: "{{Clipboard}}", - useClipboard: false, - clipboardTemplate: {}, + searchUrl: "https://example.com/search?q=%s", + selectionText: "selection", + selectionFromClipboard: true, } - expect(toUrl(param, "clip")).toBe( - "https://example.com/?prompt=%7B%7BClipboard%7D%7D", - ) + expect(toUrl(param, "clip")).toBe("https://example.com/search?q=clip") }) it("TU-09: URL encodes special characters", () => { @@ -310,6 +304,33 @@ describe("toUrl", () => { }) }) +describe("needsClipboard", () => { + const base: UrlParam = { + searchUrl: "https://example.com/search?q=%s", + selectionText: "", + } + + it("NC-01: returns false for a plain string url", () => { + expect(needsClipboard("https://example.com")).toBe(false) + }) + + it("NC-02: returns false when no part of the url uses the clipboard", () => { + expect(needsClipboard(base)).toBe(false) + }) + + it("NC-03: returns true when selectionFromClipboard is true", () => { + expect(needsClipboard({ ...base, selectionFromClipboard: true })).toBe(true) + }) + + it("NC-04: returns true when clipboardForSteps is true", () => { + expect(needsClipboard({ ...base, clipboardForSteps: true })).toBe(true) + }) + + it("NC-05: returns true when clipboardTemplate is set", () => { + expect(needsClipboard({ ...base, clipboardTemplate: {} })).toBe(true) + }) +}) + describe("matchesPageActionUrl", () => { it("MU-01: exact match without wildcard", () => { expect( diff --git a/packages/extension/src/lib/utils.ts b/packages/extension/src/lib/utils.ts index c32d68640..7d075720b 100644 --- a/packages/extension/src/lib/utils.ts +++ b/packages/extension/src/lib/utils.ts @@ -52,6 +52,20 @@ export const isUrlParam = (url: string | UrlParam): url is UrlParam => { return typeof url === "object" && "searchUrl" in url && "selectionText" in url } +/** + * Whether the clipboard must be read when opening the url. This is derived + * from how the url uses the clipboard text, rather than kept as a separate + * flag that could disagree with them. + */ +export const needsClipboard = (url: string | UrlParam): boolean => { + if (!isUrlParam(url)) return false + return ( + !!url.selectionFromClipboard || + !!url.clipboardForSteps || + url.clipboardTemplate != null + ) +} + /** * Convert bare URLs in text to Markdown link format [URL](URL). * URLs already in Markdown link format ([text](url)) are returned unchanged, @@ -77,7 +91,7 @@ export function convertUrlsToMarkdown(text: string): string { /** * Convert a string or UrlParam to a URL. * @param param The string or UrlParam to convert. - * @param clipboardText The clipboard text to use if the UrlParam has useClipboard set to true. + * @param clipboardText The clipboard text, read when needsClipboard(param) is true. * @returns The URL. */ export function toUrl( @@ -91,10 +105,10 @@ export function toUrl( searchUrl, selectionText, spaceEncoding = SPACE_ENCODING.PLUS, - useClipboard = false, + selectionFromClipboard = false, } = param let text = selectionText - if (useClipboard && param.clipboardTemplate) { + if (param.clipboardTemplate) { // selectionText is a pre-expanded prompt whose clipboard-dependent // placeholders were left unresolved, because the clipboard can only be // read here in the service worker. @@ -106,7 +120,7 @@ export function toUrl( if (param.clipboardTemplate.urlToMarkdown) { text = convertUrlsToMarkdown(text) } - } else if (useClipboard && isEmpty(text)) { + } else if (selectionFromClipboard) { text = clipboardText ?? "" } // URL encode the text diff --git a/packages/extension/src/offscreen_clipboard.html b/packages/extension/src/offscreen_clipboard.html new file mode 100644 index 000000000..6e1be4502 --- /dev/null +++ b/packages/extension/src/offscreen_clipboard.html @@ -0,0 +1,11 @@ + + + + + Selection Command Clipboard + + + + + + diff --git a/packages/extension/src/offscreen_clipboard.ts b/packages/extension/src/offscreen_clipboard.ts new file mode 100644 index 000000000..1a6a7fc9a --- /dev/null +++ b/packages/extension/src/offscreen_clipboard.ts @@ -0,0 +1,36 @@ +import type { ClipboardResult } from "./services/ipc" + +chrome.runtime.onMessage.addListener((message, sender, respond) => { + if ( + sender.id !== chrome.runtime.id || + sender.tab || + message?.target !== "offscreen-clipboard" || + message.command !== "read" + ) { + return false + } + + const textarea = document.getElementById("clipboard") as HTMLTextAreaElement + try { + textarea.value = "" + textarea.focus() + textarea.select() + // Without focus, paste may report success while inserting nothing. + if (document.activeElement !== textarea) { + throw new Error("Failed to focus the clipboard textarea") + } + // Async Clipboard requires document focus, which offscreen documents lack. + if (!document.execCommand("paste")) { + throw new Error("Failed to paste clipboard text") + } + respond({ data: textarea.value } satisfies ClipboardResult) + } catch (error) { + respond({ + data: "", + err: error instanceof Error ? error.message : String(error), + } satisfies ClipboardResult) + } finally { + textarea.value = "" + } + return false +}) diff --git a/packages/extension/src/providers/SelectContextProvider.tsx b/packages/extension/src/providers/SelectContextProvider.tsx index 4d32291ce..2e73aaaf8 100644 --- a/packages/extension/src/providers/SelectContextProvider.tsx +++ b/packages/extension/src/providers/SelectContextProvider.tsx @@ -1,5 +1,5 @@ -import { useState, ReactNode, useEffect, useCallback } from "react" -import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import { useState, ReactNode, useEffect, useCallback, useRef } from "react" +import { Ipc, TabCommand } from "@/services/ipc" import { getSelectionText } from "@/services/dom" import { ContextType, selectContext } from "@/hooks/useSelectContext" @@ -11,17 +11,17 @@ export const SelectContextProvider = ({ const [selectionText, _setSelectionText] = useState("") const [target, setTarget] = useState(null) const [detectSelectionEnabled, setDetectSelectionEnabled] = useState(true) + const selectionRef = useRef("") - const setSelectionText = useCallback(async (text: string) => { + const setSelectionText = useCallback((text: string) => { _setSelectionText(text) - await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, text) + selectionRef.current = text }, []) useEffect(() => { - const onSelectionchange = async () => { + const onSelectionchange = () => { if (!detectSelectionEnabled) return - const text = getSelectionText() - await setSelectionText(text) + setSelectionText(getSelectionText()) } document.addEventListener("selectionchange", onSelectionchange) @@ -30,6 +30,17 @@ export const SelectContextProvider = ({ } }, [detectSelectionEnabled, setSelectionText]) + useEffect(() => { + Ipc.addListener(TabCommand.getSelection, (_param, _sender, response) => { + response(selectionRef.current) + return true + }) + + return () => { + Ipc.removeListener(TabCommand.getSelection) + } + }, []) + const value: ContextType = { selectionText, setSelectionText, diff --git a/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx b/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx new file mode 100644 index 000000000..0ef68a524 --- /dev/null +++ b/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act } from "@testing-library/react" +import { SelectContextProvider } from "../SelectContextProvider" +import { Ipc, TabCommand, type IpcCallback } from "@/services/ipc" +import { getSelectionText } from "@/services/dom" + +vi.mock("@/services/ipc", () => ({ + Ipc: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + TabCommand: { + getSelection: "getSelection", + }, +})) + +vi.mock("@/services/dom", () => ({ + getSelectionText: vi.fn(), +})) + +const mockAddListener = vi.mocked(Ipc.addListener) +const mockGetSelectionText = vi.mocked(getSelectionText) + +// Calls the getSelection listener as the service worker does. +const requestSelection = () => { + const call = mockAddListener.mock.calls.find( + ([command]) => command === TabCommand.getSelection, + ) + const listener = call?.[1] as IpcCallback + const response = vi.fn() + listener(undefined, {} as chrome.runtime.MessageSender, response) + return response.mock.calls[0][0] +} + +const selectText = (text: string) => { + mockGetSelectionText.mockReturnValue(text) + act(() => { + document.dispatchEvent(new Event("selectionchange")) + }) +} + +describe("SelectContextProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("SC-01: 正常系: 現在の選択テキストを返す", () => { + render({null}) + + selectText("selected") + + expect(requestSelection()).toBe("selected") + }) + + it("SC-02: 正常系: アンマウント時にリスナーを解除する", () => { + const { unmount } = render( + {null}, + ) + + unmount() + + expect(Ipc.removeListener).toHaveBeenCalledWith(TabCommand.getSelection) + }) +}) diff --git a/packages/extension/src/service_worker.ts b/packages/extension/src/service_worker.ts index 3c7983038..602f4b7d9 100644 --- a/packages/extension/src/service_worker.ts +++ b/packages/extension/src/service_worker.ts @@ -28,7 +28,7 @@ import { findMatchingPageRule, isEmpty } from "@/lib/utils" import { execute } from "@/action/serviceWorker" import * as ActionHelper from "@/action/helper" import type { WindowType } from "@/types" -import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import { Storage } from "@/services/storage" import { ANALYTICS_EVENTS, sendEvent, @@ -390,17 +390,6 @@ const updateActiveTabId = async (activeTabId?: number) => { } } -// Clears the selection text unless the user opted to keep the menu open -// across tab/window changes. -const clearSelectionTextUnlessKeepOpen = async () => { - const settings = await enhancedSettings.getSection( - CACHE_SECTIONS.USER_SETTINGS, - ) - if (!settings.startupMethod?.keepMenuOpenOnFocusChange) { - await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, "") - } -} - chrome.action.onClicked.addListener(() => { chrome.tabs.create({ url: OPTION_PAGE_PATH, @@ -408,10 +397,7 @@ chrome.action.onClicked.addListener(() => { }) chrome.windows.onFocusChanged.addListener(async (windowId: number) => { - if (windowId === chrome.windows.WINDOW_ID_NONE) { - await clearSelectionTextUnlessKeepOpen() - return - } + if (windowId === chrome.windows.WINDOW_ID_NONE) return // Update active tab ID await updateActiveTabId() @@ -420,11 +406,7 @@ chrome.windows.onFocusChanged.addListener(async (windowId: number) => { const windowsToClose = await WindowStackManager.getWindowsToClose(windowId) // Schedule popup windows to close with configured delay. - // Run this before the settings fetch below so popup auto-close isn't - // delayed behind it and doesn't race with overlapping focus-change events. await PopupAutoClose.scheduleClose(windowsToClose) - - await clearSelectionTextUnlessKeepOpen() }) chrome.windows.onRemoved.addListener(async (windowId: number) => { @@ -677,7 +659,7 @@ Settings.addChangedListener(() => ContextMenu.init()) // ) // Add command processing -chrome.commands.onCommand.addListener(async (commandName) => { +async function handleShortcutCommand(commandName: string) { try { // Get settings const settings = await enhancedSettings.get() @@ -696,12 +678,34 @@ chrome.commands.onCommand.addListener(async (commandName) => { return } - const selectionText = await Storage.get( - SESSION_STORAGE_KEY.SELECTION_TEXT, - ) + const tab = await getCurrentTab() + const enableSendTab = + tab?.id && + !tab.url?.startsWith("chrome") && + !tab.url?.includes("chromewebstore.google.com") - // If no text is selected, handle according to noSelectionBehavior - let allowClipboardFallback = false + // A global shortcut can be pressed while another app has focus. Then the + // selection left in the page is ignored, as the user may have copied + // other text in that app. After returning to Chrome, the selection shown + // in the page is used. getLastFocused fails when no window is open. + const lastFocusedWindow = await chrome.windows + .getLastFocused() + .catch(() => undefined) + + // Ask the active tab for its latest selection. Tabs without the content + // script (e.g. chrome://) are treated as having no selection. + let selectionText = "" + if (enableSendTab && lastFocusedWindow?.focused) { + const selection = await Ipc.sendTab( + tab.id ?? 0, + TabCommand.getSelection, + ) + if (typeof selection === "string") selectionText = selection + } + + // This is the only place to decide whether to use the clipboard text as + // the selected text. Actions trust this decision and don't re-check. + let selectionFromClipboard = false if (isEmpty(selectionText)) { if ( shortcut.noSelectionBehavior === @@ -712,17 +716,10 @@ chrome.commands.onCommand.addListener(async (commandName) => { shortcut.noSelectionBehavior === SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD ) { - allowClipboardFallback = true + selectionFromClipboard = true } } - // Get active tab - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - const enableSendTab = - tab?.id && - !tab.url?.startsWith("chrome") && - !tab.url?.includes("chromewebstore.google.com") - let ret: unknown if (enableSendTab) { // Execute command in tab @@ -731,7 +728,8 @@ chrome.commands.onCommand.addListener(async (commandName) => { TabCommand.executeAction, { command, - allowClipboardFallback, + selectionText, + selectionFromClipboard, }, ) } @@ -748,7 +746,7 @@ chrome.commands.onCommand.addListener(async (commandName) => { position, selectionText, target: null, - allowClipboardFallback, + selectionFromClipboard, pageUrl: tab?.url ?? "", }) } @@ -762,7 +760,14 @@ chrome.commands.onCommand.addListener(async (commandName) => { } catch (error) { console.error("Failed to execute shortcut command:", error) } -}) +} + +chrome.commands.onCommand.addListener(handleShortcutCommand) + +// Expose only in the E2E worker for Playwright evaluation, without a message endpoint. +if (import.meta.env.MODE === "e2e") { + Object.assign(globalThis, { __e2eHandleShortcutCommand: handleShortcutCommand }) +} // Side panel is an optional capability (e.g. not available on Opera), so // each event is feature-detected individually. diff --git a/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts b/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts new file mode 100644 index 000000000..b3f3458b0 --- /dev/null +++ b/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { openPopupWindow } from "../chrome" +import { readOffscreenClipboard } from "../offscreenClipboard" +import { Ipc } from "../ipc" +import { POPUP_TYPE, WINDOW_STATE } from "@/const" + +vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() })) +vi.mock("../screen", () => ({ + getScreenSize: vi + .fn() + .mockResolvedValue({ left: 0, top: 0, width: 1280, height: 800 }), +})) +vi.mock("../windowStackManager", () => ({ + WindowStackManager: { addWindows: vi.fn().mockResolvedValue(undefined) }, +})) + +const params = { + commandId: "test", + url: { + searchUrl: "https://example.com/?q=%s", + selectionText: "", + selectionFromClipboard: true, + }, + type: POPUP_TYPE.POPUP, + top: 10, + left: 20, + width: 600, + height: 700, +} + +describe("openPopupWindow", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "clipboard text", + }) + vi.stubGlobal("chrome", { + ...chrome, + windows: { + ...chrome.windows, + getCurrent: vi.fn().mockResolvedValue({ id: 1, incognito: true }), + create: vi.fn().mockResolvedValue({ id: 2, tabs: [{ id: 20 }] }), + update: vi.fn().mockResolvedValue(undefined), + }, + declarativeNetRequest: { + ...chrome.declarativeNetRequest, + RuleActionType: { MODIFY_HEADERS: "modifyHeaders" }, + HeaderOperation: { REMOVE: "remove", SET: "set" }, + ResourceType: { MAIN_FRAME: "main_frame" }, + getSessionRules: vi.fn().mockResolvedValue([]), + updateSessionRules: vi.fn().mockResolvedValue(undefined), + }, + }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it("COP-01: 正常系: 完成したURLで一度だけ作成し位置・サイズ・シークレット設定を保持する", async () => { + expect(await openPopupWindow(params)).toEqual({ + tabId: 20, + clipboardText: "clipboard text", + }) + expect(chrome.windows.create).toHaveBeenCalledExactlyOnceWith({ + url: "https://example.com/?q=clipboard+text", + type: "popup", + top: 10, + left: 20, + width: 600, + height: 700, + state: undefined, + incognito: true, + }) + expect(chrome.tabs.update).not.toHaveBeenCalled() + }) + + it("COP-02: 正常系: fullscreenでは座標とサイズを渡さず作成後にも状態を適用する", async () => { + await openPopupWindow({ ...params, windowState: WINDOW_STATE.FULLSCREEN }) + expect(chrome.windows.create).toHaveBeenCalledWith( + expect.objectContaining({ + state: "fullscreen", + top: undefined, + left: undefined, + width: undefined, + height: undefined, + }), + ) + expect(chrome.windows.update).toHaveBeenCalledWith(2, { + state: "fullscreen", + }) + }) + + it("COP-03: 異常系: 読み取り失敗時にも表示先を作成してエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Read failed", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + await openPopupWindow(params) + expect(chrome.windows.create).toHaveBeenCalledOnce() + expect(connection).toHaveBeenCalledWith(20) + expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) +}) diff --git a/packages/extension/src/services/__tests__/chrome.openTab.test.ts b/packages/extension/src/services/__tests__/chrome.openTab.test.ts new file mode 100644 index 000000000..de10216f4 --- /dev/null +++ b/packages/extension/src/services/__tests__/chrome.openTab.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { openTab } from "../chrome" +import { Ipc } from "../ipc" +import { readOffscreenClipboard } from "../offscreenClipboard" + +vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() })) + +const sourceTab = { id: 10, windowId: 1, index: 2 } as chrome.tabs.Tab +const resultTab = { id: 20, windowId: 1 } as chrome.tabs.Tab +const url = { + searchUrl: "https://example.com/?q=%s", + selectionText: "", + selectionFromClipboard: true, +} + +describe("openTab", () => { + afterEach(() => vi.unstubAllGlobals()) + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(chrome.tabs.query).mockImplementation(() => + Promise.resolve([sourceTab]), + ) + vi.mocked(chrome.tabs.create).mockImplementation(() => + Promise.resolve(resultTab), + ) + vi.stubGlobal("chrome", { + ...chrome, + windows: { + ...chrome.windows, + create: vi.fn().mockResolvedValue({ id: 3, tabs: [{ id: 30 }] }), + remove: vi.fn().mockResolvedValue(undefined), + }, + }) + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "clipboard text", + }) + }) + + it("COT-01: 正常系: Offscreenで読み非アクティブタブを直接作成する", async () => { + const result = await openTab({ url, active: false }) + + expect(readOffscreenClipboard).toHaveBeenCalledOnce() + expect(chrome.windows.create).not.toHaveBeenCalled() + expect(chrome.tabs.create).toHaveBeenCalledExactlyOnceWith({ + url: "https://example.com/?q=clipboard+text", + active: false, + windowId: 1, + index: 3, + }) + expect(chrome.tabs.update).not.toHaveBeenCalled() + expect(result).toEqual({ tabId: 20, clipboardText: "clipboard text" }) + }) + + it("COT-01-a: 正常系: アクティブタブも完成したURLで一度だけ作成する", async () => { + await openTab({ url, active: true }) + expect(readOffscreenClipboard).toHaveBeenCalledOnce() + expect(chrome.tabs.create).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + url: "https://example.com/?q=clipboard+text", + active: true, + }), + ) + expect(chrome.tabs.update).not.toHaveBeenCalled() + }) + + it("COT-02: 正常系: クリップボード不要なら一時ウィンドウを開かない", async () => { + await openTab({ + url: { ...url, selectionText: "selected", selectionFromClipboard: false }, + active: false, + }) + + expect(readOffscreenClipboard).not.toHaveBeenCalled() + expect(chrome.windows.create).not.toHaveBeenCalled() + expect(chrome.tabs.create).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://example.com/?q=selected", + active: false, + }), + ) + }) + + it("COT-03: 異常系: 読み取り失敗時は非アクティブで開き元のタブへエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Permission denied", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + const result = await openTab({ url, active: false }) + expect(chrome.tabs.create).toHaveBeenCalledWith( + expect.objectContaining({ active: false }), + ) + expect(connection).toHaveBeenCalledWith(10) + expect(sendTab).toHaveBeenCalledWith(10, "showToast", expect.any(Object)) + expect(chrome.windows.create).not.toHaveBeenCalled() + expect(result).toEqual({ tabId: 20, clipboardText: "" }) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) + + it("COT-04: 異常系: アクティブタブで開いた場合は新しいタブへエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Permission denied", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + await openTab({ url, active: true }) + expect(connection).toHaveBeenCalledWith(20) + expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) +}) diff --git a/packages/extension/src/services/__tests__/offscreenClipboard.test.ts b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts new file mode 100644 index 000000000..fbaf9d80d --- /dev/null +++ b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { readOffscreenClipboard } from "../offscreenClipboard" + +const createDocument = vi.fn() +const getContexts = vi.fn() +const sendMessage = vi.fn() + +describe("readOffscreenClipboard", () => { + beforeEach(() => { + vi.resetAllMocks() + createDocument.mockResolvedValue(undefined) + let documentExists = false + getContexts.mockImplementation(async () => (documentExists ? [{}] : [])) + createDocument.mockImplementation(async () => { + documentExists = true + }) + sendMessage.mockResolvedValue({ data: "日本語 clipboard" }) + vi.stubGlobal("chrome", { + ...chrome, + runtime: { + ...chrome.runtime, + getURL: (path: string) => `chrome-extension://test/${path}`, + getContexts, + ContextType: { OFFSCREEN_DOCUMENT: "OFFSCREEN_DOCUMENT" }, + sendMessage, + }, + offscreen: { + createDocument, + Reason: { CLIPBOARD: "CLIPBOARD" }, + }, + }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it("OC-01: 正常系: 非表示ドキュメントを再利用し毎回最新の文字列を返す", async () => { + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledWith( + expect.objectContaining({ + url: "src/offscreen_clipboard.html", + reasons: ["CLIPBOARD"], + }), + ) + sendMessage.mockResolvedValue({ data: "updated" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ data: "updated" }) + expect(createDocument).toHaveBeenCalledOnce() + expect(sendMessage).toHaveBeenCalledTimes(2) + }) + + it("OC-02: 正常系: 再起動後に残ったドキュメントを再利用する", async () => { + getContexts.mockResolvedValue([{}]) + await readOffscreenClipboard() + expect(createDocument).not.toHaveBeenCalled() + }) + + it("OC-03: 異常系: 読み取り失敗後も次の要求で読み取れる", async () => { + sendMessage.mockRejectedValue(new Error("Read failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Read failed", + }) + sendMessage.mockResolvedValue({ data: "recovered" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "recovered", + }) + expect(createDocument).toHaveBeenCalledOnce() + }) + + it("OC-04: 正常系: 同時要求でも一度だけ作成して異なる値を順に返す", async () => { + sendMessage + .mockResolvedValueOnce({ data: "first" }) + .mockResolvedValueOnce({ data: "second" }) + const results = await Promise.all([ + readOffscreenClipboard(), + readOffscreenClipboard(), + ]) + expect(results).toEqual([{ data: "first" }, { data: "second" }]) + expect(createDocument).toHaveBeenCalledOnce() + }) + + it("OC-05: 正常系: ドキュメントが破棄されたら再作成する", async () => { + await readOffscreenClipboard() + getContexts.mockResolvedValueOnce([]) + await readOffscreenClipboard() + expect(createDocument).toHaveBeenCalledTimes(2) + }) + + it("OC-06: 異常系: 作成に一時失敗しても再試行して読み取る", async () => { + createDocument.mockRejectedValueOnce(new Error("Creation failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledTimes(2) + }) + + it("OC-07: 異常系: 一時的な読み取り失敗は再試行して読み取る", async () => { + sendMessage + .mockResolvedValueOnce({ data: "", err: "Busy" }) + .mockResolvedValueOnce({ data: "retried" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "retried", + }) + expect(sendMessage).toHaveBeenCalledTimes(2) + }) + + it("OC-08: 異常系: 再試行上限に達したら最後のエラーを返す", async () => { + sendMessage.mockRejectedValue(new Error("Read failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Read failed", + }) + expect(sendMessage).toHaveBeenCalledTimes(3) + }) + + it("OC-09: 異常系: 不正な応答はエラーとして扱う", async () => { + sendMessage.mockResolvedValue(undefined) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Invalid response from offscreen document", + }) + }) + + it("OC-10: 異常系: 作成時の競合で既存ドキュメントがあればそのまま読み取る", async () => { + createDocument.mockRejectedValueOnce( + new Error("Only a single offscreen document may be created."), + ) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledOnce() + expect(sendMessage).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/extension/src/services/chrome.ts b/packages/extension/src/services/chrome.ts index 52edba606..5f764efa1 100644 --- a/packages/extension/src/services/chrome.ts +++ b/packages/extension/src/services/chrome.ts @@ -1,16 +1,13 @@ -import { sleep, toUrl, isOverBytes, isUrlParam } from "@/lib/utils" +import { sleep, toUrl, isOverBytes, needsClipboard } from "@/lib/utils" import type { ScreenSize } from "@/services/dom" import type { ShowToastParam, UrlParam, WindowLayer } from "@/types" import { POPUP_OFFSET, POPUP_TYPE, WINDOW_STATE } from "@/const" import { PopupOption } from "@/services/option/defaultSettings" import { ServiceWorkerData } from "@/services/serviceWorkerData" import { WindowStackManager } from "@/services/windowStackManager" -import { - ServiceWorkerCommand, - ClipboardResult, - TabCommand, -} from "@/services/ipc" +import { TabCommand } from "@/services/ipc" import { getScreenSize } from "@/services/screen" +import { readOffscreenClipboard } from "@/services/offscreenClipboard" import { Ipc } from "@/services/ipc" import { t } from "@/services/i18n" import { @@ -99,7 +96,6 @@ export const fetchIconUrl = async (url: string): Promise => { url: toUrl({ searchUrl: url, selectionText: "test", - useClipboard: false, }), state: "minimized", }) @@ -136,17 +132,6 @@ export type OpenTabProps = { active: boolean } -type ReadClipboardParam = Omit & { - incognito: boolean - state?: "fullscreen" | "maximized" -} - -type ReadClipboardResult = { - clipboardText: string - window: chrome.windows.Window - err?: string -} - type OpenResult = { tabId: number | undefined clipboardText: string @@ -340,104 +325,29 @@ const updateRules = async (tabIds: number[]) => { }) } -/** - * Read clipboard content from a tab - * @param {number} tabId - The ID of the tab to read clipboard from - * @returns {Promise} The clipboard content - */ -const readClipboardContent = async ( - tabId: number, -): Promise => { - try { - const result = await new Promise((resolve) => { - const onConnect = (port: chrome.runtime.Port) => { - if (port.sender?.tab?.id !== tabId) { - return - } - const onMessage = (msg: { command: string; data: ClipboardResult }) => { - if (msg.command === ServiceWorkerCommand.setClipboard) { - port.onMessage.removeListener(onMessage) - chrome.runtime.onConnect.removeListener(onConnect) - resolve(msg.data) - } - } - port.onMessage.addListener(onMessage) - } - chrome.runtime.onConnect.addListener(onConnect) - if (chrome.runtime.lastError) { - throw new Error(chrome.runtime.lastError.message) - } - }) - - return result - } catch (e) { - console.error(e) - return { data: "", err: e instanceof Error ? e.message : "Unknown error" } - } -} - -const openWindowAndReadClipboard = async ( - param: ReadClipboardParam, -): Promise => { - const usesWindowState = - param.state === "fullscreen" || param.state === "maximized" - const w = await chrome.windows.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - focused: true, - type: param.type, - width: usesWindowState ? undefined : param.width, - height: usesWindowState ? undefined : param.height, - left: usesWindowState ? undefined : param.left, - top: usesWindowState ? undefined : param.top, - state: param.state, - incognito: param.incognito, - }) - - const tab = w?.tabs?.[0] - if (!tab?.id || !w?.id) { - throw new Error("Failed to create clipboard window") - } - const result = await readClipboardContent(tab.id) - - return { - window: w, - clipboardText: result.data ?? "", - err: result.err, - } -} - -/** - * Open a temporary window to read clipboard content and return the result. - * @returns {Promise<{ clipboardText: string; err?: string }>} The clipboard content and any error message - * - * Note: This function opens a temporary window to read the clipboard content, - * which is necessary due to browser security restrictions. - * The window is closed immediately after reading the clipboard. - */ +/** Read clipboard text through the shared offscreen document. */ export const readClipboard = async (): Promise<{ clipboardText: string err?: string }> => { - const w = await chrome.windows.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - focused: true, - type: "popup", - width: 1, - height: 1, - left: 0, - top: 0, - state: "normal", + const result = await readOffscreenClipboard() + return { clipboardText: result.data ?? "", err: result.err } +} + +/** Read clipboard text only when the URL refers to the clipboard. */ +const readClipboardIfNeeded = async ( + url: string | UrlParam, +): Promise<{ clipboardText: string; err?: string }> => + needsClipboard(url) ? readClipboard() : { clipboardText: "" } + +/** Notify the user in the given tab that reading the clipboard failed. */ +export const showClipboardErrorToast = async (tabId: number): Promise => { + await Ipc.ensureConnection(tabId) + await Ipc.sendTab(tabId, TabCommand.showToast, { + title: t("clipboard_error_title"), + description: t("clipboard_error_description"), + action: t("clipboard_error_action"), }) - const tab = w?.tabs?.[0] - if (!tab?.id || !w?.id) { - throw new Error("Failed to create clipboard window") - } - const result = await readClipboardContent(tab.id) - await closeWindow(w.id, "readClipboard close window") - return { - clipboardText: result.data ?? "", - err: result.err, - } } export const openPopupWindow = async ( @@ -472,68 +382,28 @@ export const openPopupWindow = async ( ? "maximized" : undefined - let window: chrome.windows.Window - let clipboardText = "" - - if (isUrlParam(url) && url.useClipboard) { - const result = await openWindowAndReadClipboard({ - commandId: param.commandId, - type, - width, - height, - top: at, - left: al, - state: windowState, - incognito: current.incognito, - }) - window = result.window - clipboardText = result.clipboardText - - try { - await chrome.tabs.update(window.tabs?.[0].id as number, { - url: toUrl(url, clipboardText), - }) - if (isFullscreen) { - // On macOS, even if you open with state: "fullscreen", - // it may not actually go fullscreen, so switch to fullscreen after opening. - await chrome.windows.update(window.id!, { state: "fullscreen" }) - } - } catch (e) { - console.error(e) + const clipboard = await readClipboardIfNeeded(url) + const window = (await chrome.windows.create({ + url: toUrl(url, clipboard.clipboardText), + type, + width: usesWindowState ? undefined : width, + height: usesWindowState ? undefined : height, + top: usesWindowState ? undefined : at, + left: usesWindowState ? undefined : al, + state: windowState, + incognito: current.incognito, + }))! + try { + if (isFullscreen) { + // macOS may require reapplying fullscreen after window creation. + await chrome.windows.update(window.id!, { state: "fullscreen" }) } + } catch (e) { + console.error(e) + } - if (result.err) { - await Ipc.ensureConnection(window.tabs?.[0].id as number) - await Ipc.sendTab( - window.tabs?.[0].id as number, - TabCommand.showToast, - { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }, - ) - } - } else { - window = (await chrome.windows.create({ - url: toUrl(url), - type, - width: usesWindowState ? undefined : width, - height: usesWindowState ? undefined : height, - top: usesWindowState ? undefined : at, - left: usesWindowState ? undefined : al, - state: windowState, - incognito: current.incognito, - }))! - try { - if (isFullscreen) { - // On macOS, even if you open with state: "fullscreen", - // it may not actually go fullscreen, so switch to fullscreen after opening. - await chrome.windows.update(window.id!, { state: "fullscreen" }) - } - } catch (e) { - console.error(e) - } + if (clipboard.err) { + await showClipboardErrorToast(window.tabs?.[0].id as number) } await updateServiceWorkerData([window], param.commandId, current.id, type) @@ -543,7 +413,7 @@ export const openPopupWindow = async ( return { tabId: window.tabs?.[0]?.id, - clipboardText, + clipboardText: clipboard.clipboardText, } } @@ -607,52 +477,23 @@ export const openTab = async (param: OpenTabProps): Promise => { console.warn("Failed to get current tab:", e) } - let useClipboard = false - if (isUrlParam(url)) { - useClipboard = url.useClipboard ?? false - } - - if (useClipboard) { - const tab = await chrome.tabs.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - active: true, - windowId: currentTab?.windowId, - index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, - }) - - const result = await readClipboardContent(tab.id as number) - await chrome.tabs.update(tab.id as number, { - url: toUrl(url, result.data ?? ""), - }) + const clipboard = await readClipboardIfNeeded(url) + const tab = await chrome.tabs.create({ + url: toUrl(url, clipboard.clipboardText), + active, + windowId: currentTab?.windowId, + index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, + }) - if (result.err) { - await Ipc.ensureConnection(tab.id as number) - await Ipc.sendTab( - tab.id as number, - TabCommand.showToast, - { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }, - ) - } + if (clipboard.err) { + // A background tab is not visible, so notify in the tab the user is on. + const toastTabId = active ? tab.id : (currentTab?.id ?? tab.id) + await showClipboardErrorToast(toastTabId as number) + } - return { - tabId: tab.id as number, - clipboardText: result.data ?? "", - } - } else { - const tab = await chrome.tabs.create({ - url: toUrl(url), - active, - windowId: currentTab?.windowId, - index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, - }) - return { - tabId: tab.id as number, - clipboardText: "", - } + return { + tabId: tab.id as number, + clipboardText: clipboard.clipboardText, } } diff --git a/packages/extension/src/services/contextMenus.ts b/packages/extension/src/services/contextMenus.ts index 2a707644c..461ed844e 100644 --- a/packages/extension/src/services/contextMenus.ts +++ b/packages/extension/src/services/contextMenus.ts @@ -11,8 +11,13 @@ import { export type executeActionProps = { command: Command - /** See ExecuteCommandParams.allowClipboardFallback. */ - allowClipboardFallback?: boolean + /** + * The selected text decided by the sender (e.g. the shortcut handler). + * If undefined, the current selection in the tab is used. + */ + selectionText?: string + /** See ExecuteCommandParams.selectionFromClipboard. */ + selectionFromClipboard?: boolean } type Callback = () => void diff --git a/packages/extension/src/services/ipc.ts b/packages/extension/src/services/ipc.ts index ec93cb32b..8cb5e4145 100644 --- a/packages/extension/src/services/ipc.ts +++ b/packages/extension/src/services/ipc.ts @@ -62,7 +62,6 @@ export enum ServiceWorkerCommand { toggleStar = "toggleStar", getTabId = "getTabId", getActiveTabId = "getActiveTabId", - setClipboard = "setClipboard", closeTab = "closeTab", resolveIconColors = "resolveIconColors", // Hub @@ -87,6 +86,7 @@ export enum TabCommand { ping = "ping", connected = "connected", executeAction = "executeAction", + getSelection = "getSelection", clickElement = "clickElement", closeMenu = "closeMenu", showReviewRequest = "showReviewRequest", @@ -150,7 +150,10 @@ export type SidePanelPendingAction = { selectedText: string srcUrl: string clipboardText: string - useClipboard?: boolean + /** See UrlParam.selectionFromClipboard. */ + selectionFromClipboard?: boolean + /** See UrlParam.clipboardForSteps. */ + clipboardForSteps?: boolean pageHtml?: string selectionHtml?: string userVariables?: Array diff --git a/packages/extension/src/services/offscreenClipboard.ts b/packages/extension/src/services/offscreenClipboard.ts new file mode 100644 index 000000000..096265f8e --- /dev/null +++ b/packages/extension/src/services/offscreenClipboard.ts @@ -0,0 +1,72 @@ +import type { ClipboardResult } from "./ipc" +import { sleep } from "@/lib/utils" + +const DOCUMENT_PATH = "src/offscreen_clipboard.html" +const MAX_ATTEMPTS = 3 +const RETRY_DELAY_MS = 50 + +let pendingRead: Promise = Promise.resolve() + +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const isClipboardResult = (value: unknown): value is ClipboardResult => + typeof value === "object" && + value !== null && + typeof (value as ClipboardResult).data === "string" + +async function ensureDocument(): Promise { + const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [chrome.runtime.getURL(DOCUMENT_PATH)], + }) + // Recheck after worker restarts or if Chrome discarded the document. + if (contexts.length > 0) return + try { + await chrome.offscreen.createDocument({ + url: DOCUMENT_PATH, + reasons: [chrome.offscreen.Reason.CLIPBOARD], + justification: "Read clipboard text without opening a visible window", + }) + } catch (error) { + // Another context may have created it between the check and the creation. + if (!/single offscreen document/i.test(toErrorMessage(error))) throw error + } +} + +async function readOnce(): Promise { + try { + await ensureDocument() + const result: unknown = await chrome.runtime.sendMessage({ + target: "offscreen-clipboard", + command: "read", + }) + if (!isClipboardResult(result)) { + return { data: "", err: "Invalid response from offscreen document" } + } + return result + } catch (error) { + return { data: "", err: toErrorMessage(error) } + } +} + +async function readWithRetry(): Promise { + let result: ClipboardResult = { data: "" } + for (let i = 0; i < MAX_ATTEMPTS; i++) { + result = await readOnce() + if (!result.err) return result + console.warn( + `Retry to read clipboard (attempt ${i + 1}/${MAX_ATTEMPTS}):`, + result.err, + ) + if (i < MAX_ATTEMPTS - 1) await sleep(RETRY_DELAY_MS) + } + return result +} + +export function readOffscreenClipboard(): Promise { + // Serialize reads and creation; keep the document for subsequent requests. + const read = pendingRead.then(readWithRetry) + pendingRead = read.catch(() => undefined) + return read +} diff --git a/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts b/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts new file mode 100644 index 000000000..c31bc8cb3 --- /dev/null +++ b/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest" +import { PageActionDispatcher } from "../dispatcher" +import { SelectorType, PAGE_ACTION_EVENT } from "@/const" + +const codeText = ` const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [documentUrl], + })` + +const inputCases = [ + { tag: "textarea", text: codeText }, + { + tag: "input", + text: "[KeyA] [Enter] {Enter} {Control>} [] [[ {{ [ 日本語 \\", + }, +] as const + +describe("PageActionDispatcher literal text input", () => { + afterEach(() => document.body.replaceChildren()) + + for (const source of ["Clipboard", "SelectedText", "literal"] as const) { + for (const { tag, text } of inputCases) { + it(`PDI-LITERAL-${source}-${tag}: 正常系: 括弧を含む${source}を${tag}へそのまま入力する`, async () => { + const element = document.createElement(tag) + element.id = "target" + document.body.append(element) + + const result = await PageActionDispatcher.input({ + type: PAGE_ACTION_EVENT.input, + label: "Target", + selector: "#target", + selectorType: SelectorType.css, + value: source === "literal" ? text : `{{${source}}}`, + clipboardText: source === "Clipboard" ? text : "", + selectedText: source === "SelectedText" ? text : "", + srcUrl: "https://example.com/", + }) + + expect(result).toEqual([true]) + expect(element.value).toBe(text) + }) + } + } +}) diff --git a/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts b/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts index 301fafbae..381f9a4b4 100644 --- a/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts +++ b/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts @@ -10,9 +10,9 @@ import { mockReadClipboard, mockMatchesPageActionUrl, mockIncrementCommandExecutionCount, - mockIsEmpty, mockIsUrl, mockIsUrlParam, + mockNeedsClipboard, mockConsole, } from "./serviceWorker-setup" import { openAndRun, preview, stopRunner } from "../serviceWorker" @@ -329,10 +329,10 @@ describe("serviceWorker.ts - Execution Operations", () => { expect(mockOpenTab).not.toHaveBeenCalled() }) - it("SWD-45: Normal case: Clipboard is fetched in CURRENT_TAB mode when useClipboard is true", async () => { + it("SWD-45: Normal case: Clipboard is fetched in CURRENT_TAB mode when the url needs the clipboard", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.CURRENT_TAB, - url: { url: "https://example.com", useClipboard: true }, + url: { url: "https://example.com", clipboardForSteps: true }, commandId: "cmd-1", selectedText: "selected", userVariables: [], @@ -350,6 +350,7 @@ describe("serviceWorker.ts - Execution Operations", () => { url: "https://example.com/page", }) mockIsUrlParam.mockReturnValue(true) + mockNeedsClipboard.mockReturnValue(true) mockReadClipboard.mockResolvedValue({ clipboardText: "clipboard content", err: null, @@ -368,10 +369,10 @@ describe("serviceWorker.ts - Execution Operations", () => { expect(mockIpc.ensureConnection).toHaveBeenCalledWith(555) }) - it("SWD-46: Normal case: Clipboard is NOT fetched in CURRENT_TAB mode when useClipboard is false", async () => { + it("SWD-46: Normal case: Clipboard is NOT fetched in CURRENT_TAB mode when the url does not need the clipboard", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.CURRENT_TAB, - url: { url: "https://example.com", useClipboard: false }, + url: { url: "https://example.com" }, commandId: "cmd-1", selectedText: "selected", userVariables: [], @@ -389,6 +390,7 @@ describe("serviceWorker.ts - Execution Operations", () => { url: "https://example.com/page", }) mockIsUrlParam.mockReturnValue(true) + mockNeedsClipboard.mockReturnValue(false) mockStorage.getCommands.mockResolvedValue([mockCommand]) const result = openAndRun( @@ -403,16 +405,15 @@ describe("serviceWorker.ts - Execution Operations", () => { expect(mockIpc.ensureConnection).toHaveBeenCalledWith(555) }) - it("SWD-44: Normal case: Use clipboardText when selectedText is empty and useClipboard is true", async () => { + it("SWD-44: Normal case: Use clipboardText as selectedText when selectionFromClipboard is true", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.TAB, - url: { url: "https://example.com", useClipboard: true }, + url: { url: "https://example.com", selectionFromClipboard: true }, commandId: "cmd-1", selectedText: "", steps: [{ id: "step-1", param: { type: "click" } }], } - mockIsEmpty.mockReturnValue(true) mockIsUrlParam.mockReturnValue(true) mockOpenTab.mockResolvedValue({ tabId: 456, @@ -433,6 +434,34 @@ describe("serviceWorker.ts - Execution Operations", () => { ) }) + it("SWD-44-b: Normal case: Keep selectedText empty when only the steps use the clipboard", async () => { + const mockParam = { + openMode: PAGE_ACTION_OPEN_MODE.TAB, + url: { url: "https://example.com", clipboardForSteps: true }, + commandId: "cmd-1", + selectedText: "", + steps: [{ id: "step-1", param: { type: "click" } }], + } + + mockIsUrlParam.mockReturnValue(true) + mockOpenTab.mockResolvedValue({ + tabId: 456, + clipboardText: "clipboard content", + }) + + openAndRun(mockParam as any, mockSender as any, mockResponse) + await vi.runAllTimersAsync() + + expect(mockIpc.sendTab).toHaveBeenCalledWith( + 456, + "execPageAction", + expect.objectContaining({ + selectedText: "", + clipboardText: "clipboard content", + }), + ) + }) + it("SWD-47: Error case: When tabId retrieval fails", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.TAB, diff --git a/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts b/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts index e4525285d..9ef5efdba 100644 --- a/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts +++ b/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts @@ -63,6 +63,7 @@ vi.mock("@/lib/utils", () => ({ isUrl: vi.fn(), isUrlParam: vi.fn(), matchesPageActionUrl: vi.fn(), + needsClipboard: vi.fn(), sleep: vi.fn(), })) @@ -121,6 +122,7 @@ import { isUrl, isUrlParam, matchesPageActionUrl, + needsClipboard, sleep, } from "@/lib/utils" import { resetLastUrl } from "../serviceWorker" @@ -141,6 +143,7 @@ export const mockIsEmpty = isEmpty as any export const mockIsPageActionCommand = isPageActionCommand as any export const mockIsUrl = isUrl as any export const mockIsUrlParam = isUrlParam as any +export const mockNeedsClipboard = needsClipboard as any export const mockMatchesPageActionUrl = matchesPageActionUrl as any export const mockSleep = sleep as any diff --git a/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts b/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts index 6b6ac7464..75c439dd0 100644 --- a/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts +++ b/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts @@ -267,9 +267,13 @@ describe("serviceWorker.ts - Side Panel Connection", () => { ) }) - it("SP-12: Does not call readClipboard when useClipboard is false", async () => { + it("SP-12: Does not call readClipboard when the pending action does not need the clipboard", async () => { const port = createMockPort("https://chatgpt.com") - const pending = { ...createPendingAction(), useClipboard: false } + const pending = { + ...createPendingAction(), + selectionFromClipboard: false, + clipboardForSteps: false, + } mockStorage.get.mockResolvedValue(pending) mockStorage.set.mockResolvedValue(undefined) @@ -278,11 +282,11 @@ describe("serviceWorker.ts - Side Panel Connection", () => { expect(mockReadClipboard).not.toHaveBeenCalled() }) - it("SP-13: Reads clipboard from service worker context when useClipboard is true", async () => { + it("SP-13: Reads clipboard from service worker context when the steps use the clipboard", async () => { const port = createMockPort("https://chatgpt.com") const pending = { ...createPendingAction(), - useClipboard: true, + clipboardForSteps: true, clipboardText: "", } mockStorage.get.mockResolvedValue(pending) @@ -300,6 +304,35 @@ describe("serviceWorker.ts - Side Panel Connection", () => { expect.objectContaining({ command: "execPageAction", param: expect.objectContaining({ + selectedText: "test", + clipboardText: "clipboard content", + }), + }), + ) + }) + + it("SP-13-b: Uses the clipboard text as selectedText when selectionFromClipboard is true", async () => { + const port = createMockPort("https://chatgpt.com") + const pending = { + ...createPendingAction(), + selectedText: "", + selectionFromClipboard: true, + clipboardText: "", + } + mockStorage.get.mockResolvedValue(pending) + mockStorage.set.mockResolvedValue(undefined) + mockReadClipboard.mockResolvedValue({ + clipboardText: "clipboard content", + }) + + await handleSidePanelConnect(port as any) + + expect(mockReadClipboard).toHaveBeenCalledTimes(1) + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + command: "execPageAction", + param: expect.objectContaining({ + selectedText: "clipboard content", clipboardText: "clipboard content", }), }), diff --git a/packages/extension/src/services/pageAction/dispatcher.ts b/packages/extension/src/services/pageAction/dispatcher.ts index 85339ea9d..b1e455590 100644 --- a/packages/extension/src/services/pageAction/dispatcher.ts +++ b/packages/extension/src/services/pageAction/dispatcher.ts @@ -139,7 +139,8 @@ export const PageActionDispatcher = { if (param.clearBefore) clearInput(element) await inputContentEditable(element, value, 10, null) } else { - value = value.replace(/{/g, "{{") // escape + // Treat input text literally instead of user-event keyboard descriptors. + value = value.replaceAll("{", "{{").replaceAll("[", "[[") const isTextControl = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement diff --git a/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts b/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts index 65ffd4a38..0ca88f6e8 100644 --- a/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts +++ b/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts @@ -194,13 +194,16 @@ const runPendingSidePanelAction = async ( return } await Storage.set(SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING, null) - if (pending.useClipboard) { + if (pending.selectionFromClipboard || pending.clipboardForSteps) { try { const clipboard = await readClipboard() pending.clipboardText = clipboard.clipboardText } catch (e) { console.warn("Failed to read clipboard in background:", e) } + if (pending.selectionFromClipboard) { + pending.selectedText = pending.clipboardText + } } runViaPort(port, pending) } diff --git a/packages/extension/src/services/pageAction/serviceWorker.ts b/packages/extension/src/services/pageAction/serviceWorker.ts index a13a5a4b8..3383abea4 100644 --- a/packages/extension/src/services/pageAction/serviceWorker.ts +++ b/packages/extension/src/services/pageAction/serviceWorker.ts @@ -15,6 +15,7 @@ import { getCurrentTab, OpenPopupProps, readClipboard, + showClipboardErrorToast, } from "@/services/chrome" import { incrementCommandExecutionCount } from "@/services/commandMetrics" import { @@ -28,10 +29,10 @@ import { } from "@/const" import { generateRandomID, - isEmpty, isUrl, isUrlParam, matchesPageActionUrl, + needsClipboard, sleep, } from "@/lib/utils" import type { @@ -40,9 +41,7 @@ import type { PageActionContext, PopupOption, DeepPartial, - ShowToastParam, } from "@/types" -import { t } from "@/services/i18n" import { ServiceWorkerData } from "@/services/serviceWorkerData" ServiceWorkerData.init() @@ -382,15 +381,11 @@ export const openAndRun = ( tabId = currentTab.id // Get clipboard text only when needed. - if (isUrlParam(param.url) && param.url.useClipboard) { + if (needsClipboard(param.url)) { const clipboard = await readClipboard() clipboardText = clipboard.clipboardText if (clipboard.err) { - await Ipc.sendTab(tabId, TabCommand.showToast, { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }) + await showClipboardErrorToast(tabId) } } } else { @@ -412,13 +407,7 @@ export const openAndRun = ( return } - // Use clipboard text if selected text is empty and useClipboard is true. - // This is for the case for shortcut key. - if ( - isEmpty(selectedText) && - isUrlParam(param.url) && - param.url.useClipboard - ) { + if (isUrlParam(param.url) && param.url.selectionFromClipboard) { selectedText = clipboardText } diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index c0463b3fa..525378be5 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -24,7 +24,6 @@ export enum LOCAL_STORAGE_KEY { export enum SESSION_STORAGE_KEY { SERVICE_WORKER_DATA = "serviceWorkerData", - SELECTION_TEXT = "selectionText ", SESSION_DATA = "sessionData", MESSAGE_QUEUE = "messageQueue", TMP_CAPTURES = "tmpCaptures", diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 9339ae985..46b0a2cf5 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -105,7 +105,6 @@ const DEFAULTS = { [SESSION_STORAGE_KEY.PA_CONTEXT]: {}, [SESSION_STORAGE_KEY.PA_RECORDER_OPTION]: {}, [SESSION_STORAGE_KEY.TMP_CAPTURES]: {}, - [SESSION_STORAGE_KEY.SELECTION_TEXT]: "", [SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING]: null, [SESSION_STORAGE_KEY.DEV_TOOLS_ENABLED]: false, } as const diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 83d0906fe..a0f0f3b8a 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -249,15 +249,21 @@ export type ExecuteCommandParams = { target?: Element | null useSecondary?: boolean /** - * Allows the clipboard text to be used in place of an empty selection - * (e.g. a shortcut key run without a selection). Each action decides whether - * it actually needs the clipboard, and requests it via UrlParam.useClipboard. + * Use the clipboard text as the selected text. This is decided only once, + * where the command is triggered (e.g. a shortcut key run without a + * selection), so selectionText is always empty when this is true and + * actions must not check selectionText again. */ - allowClipboardFallback?: boolean + selectionFromClipboard?: boolean changeState?: (state: ExecState, message?: string) => void pageUrl?: string } +/** Parameters each action receives. The executor fills in the defaults. */ +export type ActionExecuteParams = ExecuteCommandParams & { + selectionFromClipboard: boolean +} + export type ActionTypes = PAGE_ACTION_EVENT | PAGE_ACTION_CONTROL export type PageActionStep = { id: string @@ -321,8 +327,17 @@ export type UrlParam = { searchUrl: string selectionText: string spaceEncoding?: SPACE_ENCODING - useClipboard?: boolean pageUrl?: string + /** + * Replace the selected text in searchUrl with the clipboard text. + * See ExecuteCommandParams.selectionFromClipboard. + */ + selectionFromClipboard?: boolean + /** + * Set when page action steps reference {{Clipboard}}. The clipboard text is + * read when opening the page and passed to the steps. + */ + clipboardForSteps?: boolean /** * Set when selectionText is a prompt template (AI prompt query URL) that * still contains {{Clipboard}} / {{SelectedText}} placeholders. They are diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 1a101b89a..78846ea11 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -171,7 +171,7 @@ export default defineConfig(({ mode }) => { emptyOutDir: !isWatchMode, // prevent deleting the dist folder when running in watch mode rollupOptions: { input: { - clipboard: "src/clipboard.html", + offscreenClipboard: "src/offscreen_clipboard.html", onboarding: "src/onboarding_page.html", }, },