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}
/>