diff --git a/packages/extension/e2e/data/test-settings.json b/packages/extension/e2e/data/test-settings.json new file mode 100644 index 00000000..9bf3de70 --- /dev/null +++ b/packages/extension/e2e/data/test-settings.json @@ -0,0 +1,379 @@ +{ + "commands": [ + { + "iconUrl": "https://ujiro99.github.io/selection-command/favicon.ico", + "id": "13b4e831-7fac-4b72-a1ae-c82655b3819e", + "openMode": "popup", + "openModeSecondary": "tab", + "parentFolderId": "RootFolder", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s", + "spaceEncoding": "plus", + "title": "テストページ検索" + }, + { + "iconUrl": "https://www.google.com/favicon.ico", + "id": "0cb9dbbc-c0cf-53c6-93e5-016363705216", + "openMode": "popup", + "openModeSecondary": "tab", + "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://google.com/search?q=%s", + "spaceEncoding": "plus", + "title": "Google" + }, + { + "iconUrl": "https://www.google.com/favicon.ico", + "id": "26c47b36-c3c8-528c-9ad2-c972dfc6f4df", + "openMode": "popup", + "openModeSecondary": "tab", + "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://google.com/search?q=%s&tbm=isch", + "spaceEncoding": "plus", + "title": "Google Image" + }, + { + "iconUrl": "https://www.amazon.co.jp/favicon.ico", + "id": "9d61d45c-36ab-5ebf-ad42-d3f3a42810bf", + "openMode": "tab", + "openModeSecondary": "tab", + "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://www.amazon.co.jp/s?k=%s", + "spaceEncoding": "plus", + "title": "Amazon" + }, + { + "iconUrl": "https://s.yimg.jp/c/icon/s/bsc/2.0/favicon.ico", + "id": "2bcb5d3a-15b6-5e3f-b59d-94b0fdc68ea9", + "openMode": "popup", + "openModeSecondary": "tab", + "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://search.yahoo.co.jp/search?p=%s", + "spaceEncoding": "plus", + "title": "Yahoo! Japan" + }, + { + "iconUrl": "https://www.youtube.com/s/desktop/f574e7a2/img/favicon_32x32.png", + "id": "2b6fee1e-6500-5421-af79-6fa53ddc25c1", + "openMode": "tab", + "openModeSecondary": "tab", + "parentFolderId": "a3495269-0a4d-4866-a519-bca75ed1c246", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://www.youtube.com/results?search_query=%s", + "spaceEncoding": "plus", + "title": "Youtube" + }, + { + "iconUrl": "https://assets.nflxext.com/ffe/siteui/common/icons/nficon2016.ico", + "id": "fb9cb6ad-76e3-5aa8-82a7-ade233edcec0", + "openMode": "tab", + "openModeSecondary": "tab", + "parentFolderId": "a3495269-0a4d-4866-a519-bca75ed1c246", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://www.netflix.com/search?q=%s", + "spaceEncoding": "plus", + "title": "Netflix" + }, + { + "aiPromptOption": { + "openMode": "popup", + "prompt": "以下について解説してください。\n{{SelectedText}}", + "serviceId": "gemini" + }, + "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg", + "id": "1d320825-1e78-5f98-b73c-1bb48412e98c", + "openMode": "aiPrompt", + "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "Gemini - 日本語" + }, + { + "aiPromptOption": { + "openMode": "sidePanel", + "prompt": "以下のURLのページを日本語で要約してください。\n{{Url}}", + "serviceId": "gemini" + }, + "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg", + "id": "afe67f66-fc8d-555f-9e51-2d1491906faf", + "openMode": "aiPrompt", + "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "ページの概要生成" + }, + { + "aiPromptOption": { + "openMode": "sidePanel", + "prompt": "以下のYouTube動画の内容を日本語で要約してください。\n{{Url}}", + "serviceId": "gemini" + }, + "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg", + "id": "7afd0cb7-45a4-5943-a00d-b04d12317eb1", + "openMode": "aiPrompt", + "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "YouTubeの概要生成" + }, + { + "aiPromptOption": { + "openMode": "sidePanel", + "prompt": "以下のテキストを日本語と英語の間で翻訳してください。\n{{SelectedText}}", + "serviceId": "gemini" + }, + "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg", + "id": "a8d027bf-7926-56c4-ad4d-610ef10c22b3", + "openMode": "aiPrompt", + "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "選択テキストの相互翻訳" + }, + { + "iconUrl": "https://web-toolbox.dev/favicon.svg", + "id": "03646140-c83f-5ee6-87ba-8feb12030af0", + "openMode": "pageAction", + "pageActionOption": { + "openMode": "popup", + "startUrl": "https://web-toolbox.dev/tools/character-counter", + "steps": [ + { + "id": "b28aqptaq", + "param": { + "label": "Start", + "type": "start", + "url": "https://web-toolbox.dev/tools/character-counter" + } + }, + { + "delayMs": 100, + "id": "xswttnk5r", + "param": { + "label": "textarea", + "selector": "//textarea", + "selectorType": "xpath", + "type": "click" + } + }, + { + "id": "pycqdt0ap", + "param": { + "label": "文字入力", + "selector": "//textarea", + "selectorType": "xpath", + "type": "input", + "value": "{{SelectedText}}" + } + }, + { + "id": "8tsr1lz9m", + "param": { + "label": "表示位置までスクロール", + "type": "scroll", + "x": 0, + "y": 812 + } + }, + { + "id": "erkxkfph0", + "param": { + "label": "End", + "type": "end" + } + } + ] + }, + "parentFolderId": "0f2167ab-2e1b-4972-954c-71eec058ab14", + "revision": 0, + "title": "Character Counter" + }, + { + "iconUrl": "https://ssl.gstatic.com/docs/doclist/images/drive_2022q3_32dp.png", + "id": "dd05d527-92db-5102-9a88-4a5b31fa7512", + "openMode": "tab", + "openModeSecondary": "tab", + "parentFolderId": "01710cf1-ec8b-497f-8d1f-9cb716567bc4", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://drive.google.com/drive/search?q=%s", + "spaceEncoding": "plus", + "title": "Drive" + }, + { + "iconUrl": "https://ssl.gstatic.com/translate/favicon.ico", + "id": "9a3fca67-e618-5dd3-9ecd-9eb2d088041a", + "openMode": "tab", + "openModeSecondary": "tab", + "parentFolderId": "01710cf1-ec8b-497f-8d1f-9cb716567bc4", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://translate.google.co.jp/?hl=ja&sl=auto&text=%s&op=translate", + "spaceEncoding": "plus", + "title": "en to ja" + }, + { + "id": "$$drag-1", + "openMode": "previewPopup", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "Link Preview" + } + ], + "folders": [ + { + "iconUrl": "https://cdn3.iconfinder.com/data/icons/feather-5/24/search-1024.png", + "id": "222d6489-4eca-48fd-8590-fceb30545bab", + "onlyIcon": true, + "title": "Search" + }, + { + "iconSvg": "", + "iconUrl": "", + "id": "0f2167ab-2e1b-4972-954c-71eec058ab14", + "onlyIcon": true, + "title": "Action" + }, + { + "iconSvg": "", + "iconUrl": "", + "id": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae", + "onlyIcon": true, + "title": "AI" + }, + { + "iconSvg": "", + "iconUrl": "", + "id": "a3495269-0a4d-4866-a519-bca75ed1c246", + "onlyIcon": true, + "title": "Media" + }, + { + "iconSvg": "", + "iconUrl": "", + "id": "01710cf1-ec8b-497f-8d1f-9cb716567bc4", + "title": "Work" + } + ], + "linkCommand": { + "enabled": "Enable", + "openMode": "previewPopup", + "showIndicator": true, + "sidePanelAutoHide": false, + "startupMethod": { + "keyboardParam": "Shift", + "leftClickHoldParam": 200, + "method": "keyboard", + "threshold": 150 + } + }, + "pageRules": [], + "popupPlacement": { + "align": "start", + "alignOffset": 0, + "side": "top", + "sideOffset": 0 + }, + "settingVersion": "0.16.0", + "shortcuts": { + "shortcuts": [ + { + "commandId": "_placeholder_", + "id": "slot_1", + "noSelectionBehavior": "useClipboard" + }, + { + "commandId": "_placeholder_", + "id": "slot_2", + "noSelectionBehavior": "useClipboard" + }, + { + "commandId": "_placeholder_", + "id": "slot_3", + "noSelectionBehavior": "useClipboard" + } + ] + }, + "startupMethod": { + "method": "textSelection" + }, + "style": "horizontal", + "userStyles": [ + { + "name": "popup-delay", + "value": 250 + }, + { + "name": "popup-duration", + "value": 150 + }, + { + "name": "padding-scale", + "value": "1.5" + }, + { + "name": "image-scale", + "value": "1.1" + }, + { + "name": "font-scale", + "value": "1.1" + } + ], + "windowOption": { + "popupAutoCloseDelay": 0, + "sidePanelAutoHide": false + } +} \ No newline at end of file diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index 34ff78e2..71c172e9 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "./fixtures" import { TestPage } from "./pages/TestPage" +import { OptionsPage } from "./pages/OptionsPage" /** * E2E-01: Verify that the extension content script is injected into the test page. @@ -24,41 +25,31 @@ test("E2E-02: popup menu appears on text selection", async ({ page }) => { expect(menubar.isVisible()) }) -test("E2E-03: ポップアップメニューからコマンド実行し、PopupウィンドウでGoogle検索が実行されること", async ({ +test("E2E-03: executing a command from the popup menu performs search on test page in a popup window", async ({ context, + extensionId, + getUserSettings, page, }) => { + // Import test settings to ensure the first menu item is a Testpage command. + const optionsPage = new OptionsPage(context, extensionId, getUserSettings) + await optionsPage.open() + await optionsPage.importSettings() + await optionsPage.close() + + // Arrange: Open the test page and select text to show the popup menu. const testPage = new TestPage(page) await testPage.open() - await testPage.selectText() + await testPage.selectText("h2") const menubar = await testPage.getMenuBar() - // Wait for a new popup window to be created when the button is clicked. + // Act: Wait for a new popup window to be created when the button is clicked. const [popupPage] = await Promise.all([ context.waitForEvent("page"), menubar.locator("button").first().click(), ]) - await popupPage.waitForLoadState("domcontentloaded") - expect(popupPage.url()).toContain("google.com/search?q=") -}) - -test("E2E-04: コンテキスメニューからコマンド実行し、PopupウィンドウでGoogle検索が実行されること", async ({ - context, - page, - getUserSettings, -}) => { - const testPage = new TestPage(page) - await testPage.open() - await testPage.selectText() - const menubar = await testPage.getMenuBar() - await menubar.locator("button").first().click() - - let [serviceWorker] = context.serviceWorkers() - if (!serviceWorker) { - serviceWorker = await context.waitForEvent("serviceworker") - } - const reuslt = await getUserSettings() - console.log("userSettings", reuslt) + // Assert + expect(popupPage.url()).toContain("?k=Browser") }) diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index 3d750acf..cbbf78ae 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -11,6 +11,11 @@ import { fileURLToPath } from "url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const pathToExtension = path.join(__dirname, "../dist") +type StorageChangeMap = { + [key: string]: chrome.storage.StorageChange +} +export type WaitForStorageChange = () => Promise + type Fixtures = { context: BrowserContext extensionId: string @@ -24,7 +29,7 @@ type Fixtures = { */ export const test = base.extend({ // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { + context: async ({ }, use) => { // When running with --debug, PWDEBUG is set; show the browser window in that case. const isDebug = !!process.env.PWDEBUG const context = await chromium.launchPersistentContext("", { diff --git a/packages/extension/e2e/pages/OptionsPage.ts b/packages/extension/e2e/pages/OptionsPage.ts new file mode 100644 index 00000000..75d53bb7 --- /dev/null +++ b/packages/extension/e2e/pages/OptionsPage.ts @@ -0,0 +1,100 @@ +import path from "path" + +import { expect, Page, type BrowserContext } from "@playwright/test" + +import { TEST_IDS } from "@/testIds" +import { fileURLToPath } from "url" +import type { UserSettings } from "@/types" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const TEST_SETTINGS_PATH = path.join(__dirname, "../data/test-settings.json") + +/** + * Page Object for the extension's options page. + * Encapsulates navigation and settings import interactions. + */ +export class OptionsPage { + private page: Page | null + + constructor( + private readonly context: BrowserContext, + private readonly extensionId: string, + private readonly getUserSettings: () => Promise, + ) { + this.page = null + } + + /** + * Navigate to the extension's options page. + */ + async open(): Promise { + const url = `chrome-extension://${this.extensionId}/src/options_page.html` + this.page = await this.context.newPage() + await this.page.goto(url) + await this.page.waitForLoadState("domcontentloaded") + } + + /** + * Close the options page if it's open. + * Ensures that resources are cleaned up after tests. + */ + async close(): Promise { + if (this.page) { + await this.page.close() + this.page = null + } + } + + /** + * Import test settings from the test-settings.json file. + * + * Steps: + * 1. Click the import button to open the import dialog. + * 2. Set the test-settings.json file on the file input. + * 3. Wait for the file to be read and OK button to be enabled. + * 4. Click OK to execute the import. + * 5. Wait for the page to reload and settings to be saved. + */ + async importSettings(): Promise { + if (!this.page) { + await this.open() + } + const page = this.page! + + // Open the import dialog + await page.locator(`[data-testid="${TEST_IDS.importButton}"]`).click() + + // Set the file on the hidden file input + const fileInput = page.locator( + `[data-testid="${TEST_IDS.importFileInput}"]`, + ) + await fileInput.setInputFiles(TEST_SETTINGS_PATH) + + // Wait for the file to be read and OK button to be enabled + const okButton = page.locator(`[data-testid="${TEST_IDS.optionDialogOk}"]`) + await page.waitForFunction( + (testId) => { + const button = document.querySelector( + `[data-testid="${testId}"]`, + ) as HTMLButtonElement + return button && !button.disabled + }, + TEST_IDS.optionDialogOk, + { timeout: 5000 }, + ) + + // Confirm the import and wait for page reload + const reloadPromise = page.waitForLoadState("domcontentloaded") + await okButton.click() + await reloadPromise + + // Wait for the settings to be loaded with commands + await expect + .poll(async () => await this.getUserSettings(), { + message: "User settings should be loaded with commands after import", + timeout: 5000, + intervals: [40], + }) + .not.toBeUndefined() + } +} diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index 789a1a8b..823d0670 100644 --- a/packages/extension/e2e/pages/TestPage.ts +++ b/packages/extension/e2e/pages/TestPage.ts @@ -9,7 +9,7 @@ const APP_ID = "selection-command" * Encapsulates navigation and user interactions specific to this page. */ export class TestPage { - constructor(private readonly page: Page) {} + constructor(private readonly page: Page) { } /** * Navigate to the test page and wait until the extension content script is injected. @@ -39,10 +39,10 @@ export class TestPage { * listeners are registered the popup appears within 250ms and the next poll * detects it. */ - async selectText(): Promise { + async selectText(cssSelector = "h1, h2, h3"): Promise { await this.page.waitForFunction( - (testIds) => { - const heading = document.querySelector("h1, h2, h3") + ({ testIds, appId, cssSelector }) => { + const heading = document.querySelector(cssSelector) if (!heading) return false // Scroll into view so getBoundingClientRect() returns valid coordinates. @@ -87,13 +87,13 @@ export class TestPage { // The popup portals into document.body via Radix UI. It appears after a // ~250ms delay. Polling at 300ms gives the popup time to render before // the next check. - const el = document.getElementById(testIds.appId) + const el = document.getElementById(appId) return ( el?.shadowRoot?.querySelector(`[data-testid='${testIds.menuBar}']`) != null ) }, - { ...TEST_IDS, appId: APP_ID }, + { testIds: TEST_IDS, appId: APP_ID, cssSelector }, { polling: 300, timeout: 10_000 }, ) } diff --git a/packages/extension/src/components/option/Dialog.tsx b/packages/extension/src/components/option/Dialog.tsx index e7475fc1..25dce37f 100644 --- a/packages/extension/src/components/option/Dialog.tsx +++ b/packages/extension/src/components/option/Dialog.tsx @@ -8,6 +8,7 @@ import { DialogTitle, DialogPortal, } from "@/components/ui/dialog" +import { TEST_IDS } from "@/testIds" import { t } from "@/services/i18n" import { cn } from "@/lib/utils" @@ -46,12 +47,14 @@ export function Dialog(props: Props) { className={cn(css.button, "disabled:opacity-50")} onClick={() => props.onClose(true)} disabled={props.okDisabled} + data-testid={TEST_IDS.optionDialogOk} > {props.okText} diff --git a/packages/extension/src/components/option/ImportExport.tsx b/packages/extension/src/components/option/ImportExport.tsx index e49877b1..85f807f0 100644 --- a/packages/extension/src/components/option/ImportExport.tsx +++ b/packages/extension/src/components/option/ImportExport.tsx @@ -17,6 +17,7 @@ import { t } from "@/services/i18n" import { Download, Upload, Undo2, RotateCcw } from "lucide-react" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import type { BackupData } from "@/services/storage/backupManager" +import { TEST_IDS } from "@/testIds" import css from "./Option.module.css" @@ -263,7 +264,7 @@ export function ImportExport() { const handleImportClose = (ret: boolean) => { if (ret && importJson != null) { - ; (async () => { + ;(async () => { const { commandExecutionCount = 0, hasShownReviewRequest = false, @@ -289,7 +290,7 @@ export function ImportExport() { const handleRestoreClose = (ret: boolean) => { if (ret) { - ; (async () => { + ;(async () => { try { let backupCommands: any[] = [] @@ -365,6 +366,7 @@ export function ImportExport() { onClick={() => setImportDialog(true)} className={css.menuButton} type="button" + data-testid={TEST_IDS.importButton} > {t("Option_Import")} @@ -383,8 +385,8 @@ export function ImportExport() { ) ? t("Option_RestoreFromBackup_checking") : !Object.values(backupData).some( - (backup) => backup.status === BACKUP_STATUS.AVAILABLE, - ) + (backup) => backup.status === BACKUP_STATUS.AVAILABLE, + ) ? t("Option_RestoreFromBackup_no_backup") : t("Option_RestoreFromBackup_tooltip") } @@ -426,6 +428,7 @@ export function ImportExport() { onChange={handleImport} ref={inputFile} className={`${css.buttonImport}`} + data-testid={TEST_IDS.importFileInput} /> ', + '', id: FOLDER_AI, onlyIcon: true, }, diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts index fb073032..4c7dd49c 100644 --- a/packages/extension/src/testIds.ts +++ b/packages/extension/src/testIds.ts @@ -1,3 +1,7 @@ export const TEST_IDS = { menuBar: "menu-bar", + importButton: "import-button", + importFileInput: "import-file-input", + optionDialogOk: "option-dialog-ok", + optionDialogCancel: "option-dialog-cancel", }