From 50c47b81d43bd520b57bd75b6a9d824d681a779b Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 18 Mar 2026 12:40:33 +0900 Subject: [PATCH 01/38] Update: Initialize default settings on install extension. --- packages/extension/src/background_script.ts | 63 +++++++++++---------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 736f6034..0988ec8a 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -60,7 +60,7 @@ const getActiveTabId = ( return true } -const onConnect = async function(port: chrome.runtime.Port) { +const onConnect = async function (port: chrome.runtime.Port) { if (port.name !== CONNECTION_APP) return port.onDisconnect.addListener(() => onDisconnect(port)) const tabId = port.sender?.tab?.id @@ -73,7 +73,7 @@ const onConnect = async function(port: chrome.runtime.Port) { await PageActionBackground.handleSidePanelConnect(port) } } -const onDisconnect = async function(port: chrome.runtime.Port) { +const onDisconnect = async function (port: chrome.runtime.Port) { if (port.name !== CONNECTION_APP) return if (chrome.runtime.lastError) { if ( @@ -170,24 +170,24 @@ const commandFuncs = { const cmd = isSearch ? { - id: params.id, - title: params.title, - searchUrl: params.searchUrl, - iconUrl: params.iconUrl, - openMode: params.openMode, - openModeSecondary: params.openModeSecondary, - spaceEncoding: params.spaceEncoding, - popupOption: PopupOption, - } - : isPageAction - ? { id: params.id, title: params.title, + searchUrl: params.searchUrl, iconUrl: params.iconUrl, openMode: params.openMode, - pageActionOption: params.pageActionOption, + openModeSecondary: params.openModeSecondary, + spaceEncoding: params.spaceEncoding, popupOption: PopupOption, } + : isPageAction + ? { + id: params.id, + title: params.title, + iconUrl: params.iconUrl, + openMode: params.openMode, + pageActionOption: params.pageActionOption, + popupOption: PopupOption, + } : null if (!cmd) { @@ -474,8 +474,13 @@ if (isDebug) { }) } -chrome.runtime.onInstalled.addListener((details) => { - ContextMenu.init() +chrome.runtime.onInstalled.addListener(async (details) => { + // Initialize default settings on install + if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { + await Settings.reset() + } + + await ContextMenu.init() chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS", @@ -490,10 +495,10 @@ chrome.runtime.onInstalled.addListener((details) => { } // Check for daily backup on startup - checkAndPerformDailyBackup() + await checkAndPerformDailyBackup() // Check for weekly backup on startup - checkAndPerformWeeklyBackup() + await checkAndPerformWeeklyBackup() }) chrome.runtime.onStartup.addListener(() => { @@ -545,17 +550,17 @@ const checkAndPerformLegacyBackup = async () => { } } - // Initialize commandIdObj and register listener at top-level - // to ensure they are available when service worker restarts - ; (async () => { - try { - await ContextMenu.syncCommandIdObj() - chrome.contextMenus.onClicked.addListener(ContextMenu.onClicked) - } catch (error) { - // Ignore errors during initialization (e.g., in test environment) - console.debug("Failed to initialize context menu listener:", error) - } - })() +// Initialize commandIdObj and register listener at top-level +// to ensure they are available when service worker restarts +;(async () => { + try { + await ContextMenu.syncCommandIdObj() + chrome.contextMenus.onClicked.addListener(ContextMenu.onClicked) + } catch (error) { + // Ignore errors during initialization (e.g., in test environment) + console.debug("Failed to initialize context menu listener:", error) + } +})() Settings.addChangedListener(() => ContextMenu.init()) From cce186811b05a8aa1a1fb16b5b44d8737d3fe2c1 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 18 Mar 2026 13:42:33 +0900 Subject: [PATCH 02/38] Update: Add testcases. --- packages/extension/e2e/extension.spec.ts | 33 ++++++++++++++++++--- packages/extension/e2e/fixtures.ts | 33 +++++++++++++++++++-- packages/extension/e2e/pages/OptionsPage.ts | 18 +++++++---- packages/extension/e2e/pages/TestPage.ts | 17 ++++------- packages/extension/src/const.ts | 6 ++-- 5 files changed, 81 insertions(+), 26 deletions(-) diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index 71c172e9..9e503df7 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "./fixtures" import { TestPage } from "./pages/TestPage" import { OptionsPage } from "./pages/OptionsPage" +import { STARTUP_METHOD, KEYBOARD } from "../src/const" /** * E2E-01: Verify that the extension content script is injected into the test page. @@ -15,7 +16,7 @@ test("E2E-01: extension is injected into the test page", async ({ page }) => { * E2E-02: Verify that the popup menu appears when text is selected on the page. * Double-clicking on a word triggers text selection and shows the popup menu. */ -test("E2E-02: popup menu appears on text selection", async ({ page }) => { +test("E2E-10: popup menu appears on text selection", async ({ page }) => { const testPage = new TestPage(page) await testPage.open() @@ -25,14 +26,38 @@ test("E2E-02: popup menu appears on text selection", async ({ page }) => { expect(menubar.isVisible()) }) -test("E2E-03: executing a command from the popup menu performs search on test page in a popup window", async ({ +test("E2E-11: popup menu appears on text selection and press a ShiftKey", async ({ + setUserSettings, + page, +}) => { + // Arrange: Set the startup method to "keyboard". + const testPage = new TestPage(page) + await testPage.open() + + await setUserSettings({ + startupMethod: { + method: STARTUP_METHOD.KEYBOARD, + keyboardParam: KEYBOARD.SHIFT, + }, + }) + await testPage.selectText() + await page.keyboard.press(KEYBOARD.SHIFT) + + // Act: Set the startup method to "shortcut" and dispatch the keyboard shortcut. + const menubar = await testPage.getMenuBar() + + // Asert + expect(menubar.isVisible()) +}) + +test("E2E-20: executing a command from the popup menu performs search on test page in a popup window", async ({ context, extensionId, - getUserSettings, + getCommands, page, }) => { // Import test settings to ensure the first menu item is a Testpage command. - const optionsPage = new OptionsPage(context, extensionId, getUserSettings) + const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.importSettings() await optionsPage.close() diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index cbbf78ae..82aa64c8 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -5,9 +5,11 @@ import { type BrowserContext, } from "@playwright/test" import path from "path" -import type { UserSettings } from "@/types" import { fileURLToPath } from "url" +import type { UserSettings, Command } from "@/types" +import type { CommandMetadata } from "@/types/command" + const __dirname = path.dirname(fileURLToPath(import.meta.url)) const pathToExtension = path.join(__dirname, "../dist") @@ -20,8 +22,10 @@ type Fixtures = { context: BrowserContext extensionId: string extensionBackground: Page + getSyncStorage: (key: string) => Promise getUserSettings: () => Promise setUserSettings: (newSettings: Partial) => Promise + getCommands: () => Promise } /** @@ -29,7 +33,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("", { @@ -70,6 +74,31 @@ export const test = base.extend({ await bg.close() }, + getCommands: async ({ context }, use) => { + let [serviceWorker] = context.serviceWorkers() + if (!serviceWorker) { + serviceWorker = await context.waitForEvent("serviceworker") + } + await use(async () => { + const result = await serviceWorker.evaluate(async () => { + const { 5: metaData } = await chrome.storage.sync.get<{ + "5": CommandMetadata + }>("5") + const count = metaData.count + + const CMD_PREFIX = "cmd-" + const cmdSyncKey = (idx: number): string => `${CMD_PREFIX}${idx}` + const keys = Array.from({ length: count }, (_, i) => cmdSyncKey(i)) + const result = await chrome.storage.sync.get(keys) + + return keys + .map((key) => result[key] as Command) + .filter((cmd) => cmd != null) + }) + return result + }) + }, + getUserSettings: async ({ context }, use) => { let [serviceWorker] = context.serviceWorkers() if (!serviceWorker) { diff --git a/packages/extension/e2e/pages/OptionsPage.ts b/packages/extension/e2e/pages/OptionsPage.ts index 5a4cf498..d6dbb8aa 100644 --- a/packages/extension/e2e/pages/OptionsPage.ts +++ b/packages/extension/e2e/pages/OptionsPage.ts @@ -10,6 +10,9 @@ function sleep(msec: number): Promise { return new Promise((resolve) => setTimeout(resolve, msec)) } +// Load test settings from the JSON file. +// This is used to provide consistent test data for the importSettings method. +import testSettings from "../data/test-settings.json" with { type: "json" } const __dirname = path.dirname(fileURLToPath(import.meta.url)) const TEST_SETTINGS_PATH = path.join(__dirname, "../data/test-settings.json") @@ -23,7 +26,7 @@ export class OptionsPage { constructor( private readonly context: BrowserContext, private readonly extensionId: string, - private readonly getUserSettings: () => Promise, + private readonly getCommands: () => Promise, ) { this.page = null } @@ -97,17 +100,20 @@ export class OptionsPage { await reloadPromise // Wait for the settings to be loaded with commands - let settings + let commands let timeout = 5000 // Maximum wait time of 5 seconds const interval = 40 // milliseconds do { await sleep(interval) - settings = await this.getUserSettings() + commands = await this.getCommands() timeout -= interval - } while (settings == null && timeout > 0) + } while ( + (commands == null || commands.length !== testSettings.commands.length) && + timeout > 0 + ) - if (settings == null) { - console.error("Failed to import settings") + if (commands == null || commands.length !== testSettings.commands.length) { + console.error("Failed to import settings", commands?.length) throw new Error("Failed to import settings") } } diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index 823d0670..780db536 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. @@ -41,7 +41,7 @@ export class TestPage { */ async selectText(cssSelector = "h1, h2, h3"): Promise { await this.page.waitForFunction( - ({ testIds, appId, cssSelector }) => { + ({ cssSelector }) => { const heading = document.querySelector(cssSelector) if (!heading) return false @@ -84,17 +84,10 @@ 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(appId) - return ( - el?.shadowRoot?.querySelector(`[data-testid='${testIds.menuBar}']`) != - null - ) + return true }, - { testIds: TEST_IDS, appId: APP_ID, cssSelector }, - { polling: 300, timeout: 10_000 }, + { cssSelector }, + { polling: 50, timeout: 10_000 }, ) } diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index 6cd43bcf..49cd7f03 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -2,13 +2,15 @@ import { OPEN_MODE } from "@shared" export { OPEN_MODE, PAGE_ACTION_OPEN_MODE, SEARCH_OPEN_MODE } from "@shared" export const APP_ID = "selection-command" -export const VERSION = __APP_VERSION__ as string +export const VERSION = ( + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.0.0" +) as string /** * Setting value to switch the debug log output from this module. * true: enables all log. | false: disables debug log. */ -const environment = import.meta.env.MODE ?? "development" +const environment = import.meta.env?.MODE ?? "development" export const isDebug = environment === "development" export const isE2E = environment === "e2e" From 5f3c0bc662fe5fb630a155c98077791d72865474 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Sat, 21 Mar 2026 09:40:07 +0900 Subject: [PATCH 03/38] Update: Add e2e tests for menu-style and settigs. --- .../e2e/data/test-settings-100-commands.json | 1474 +++++++++++++++++ .../e2e/data/test-settings-link-preview.json | 60 + .../extension/e2e/data/test-settings.json | 174 +- packages/extension/e2e/fixtures.ts | 35 +- packages/extension/e2e/menu-style.spec.ts | 59 + packages/extension/e2e/pages/OptionsPage.ts | 79 +- packages/extension/e2e/settings.spec.ts | 130 ++ .../src/components/option/ImportExport.tsx | 22 +- packages/extension/src/content_script.tsx | 5 +- packages/extension/src/testIds.ts | 2 + packages/extension/vite.config.ts | 40 +- 11 files changed, 2024 insertions(+), 56 deletions(-) create mode 100644 packages/extension/e2e/data/test-settings-100-commands.json create mode 100644 packages/extension/e2e/data/test-settings-link-preview.json create mode 100644 packages/extension/e2e/menu-style.spec.ts create mode 100644 packages/extension/e2e/settings.spec.ts diff --git a/packages/extension/e2e/data/test-settings-100-commands.json b/packages/extension/e2e/data/test-settings-100-commands.json new file mode 100644 index 00000000..21cb4445 --- /dev/null +++ b/packages/extension/e2e/data/test-settings-100-commands.json @@ -0,0 +1,1474 @@ +{ + "commands": [ + { + "id": "$$drag-1", + "openMode": "previewPopup", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "Link Preview" + }, + { + "id": "cmd-bulk-001", + "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": "Test Command 001" + }, + { + "id": "cmd-bulk-002", + "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": "Test Command 002" + }, + { + "id": "cmd-bulk-003", + "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": "Test Command 003" + }, + { + "id": "cmd-bulk-004", + "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": "Test Command 004" + }, + { + "id": "cmd-bulk-005", + "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": "Test Command 005" + }, + { + "id": "cmd-bulk-006", + "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": "Test Command 006" + }, + { + "id": "cmd-bulk-007", + "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": "Test Command 007" + }, + { + "id": "cmd-bulk-008", + "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": "Test Command 008" + }, + { + "id": "cmd-bulk-009", + "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": "Test Command 009" + }, + { + "id": "cmd-bulk-010", + "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": "Test Command 010" + }, + { + "id": "cmd-bulk-011", + "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": "Test Command 011" + }, + { + "id": "cmd-bulk-012", + "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": "Test Command 012" + }, + { + "id": "cmd-bulk-013", + "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": "Test Command 013" + }, + { + "id": "cmd-bulk-014", + "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": "Test Command 014" + }, + { + "id": "cmd-bulk-015", + "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": "Test Command 015" + }, + { + "id": "cmd-bulk-016", + "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": "Test Command 016" + }, + { + "id": "cmd-bulk-017", + "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": "Test Command 017" + }, + { + "id": "cmd-bulk-018", + "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": "Test Command 018" + }, + { + "id": "cmd-bulk-019", + "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": "Test Command 019" + }, + { + "id": "cmd-bulk-020", + "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": "Test Command 020" + }, + { + "id": "cmd-bulk-021", + "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": "Test Command 021" + }, + { + "id": "cmd-bulk-022", + "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": "Test Command 022" + }, + { + "id": "cmd-bulk-023", + "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": "Test Command 023" + }, + { + "id": "cmd-bulk-024", + "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": "Test Command 024" + }, + { + "id": "cmd-bulk-025", + "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": "Test Command 025" + }, + { + "id": "cmd-bulk-026", + "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": "Test Command 026" + }, + { + "id": "cmd-bulk-027", + "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": "Test Command 027" + }, + { + "id": "cmd-bulk-028", + "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": "Test Command 028" + }, + { + "id": "cmd-bulk-029", + "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": "Test Command 029" + }, + { + "id": "cmd-bulk-030", + "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": "Test Command 030" + }, + { + "id": "cmd-bulk-031", + "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": "Test Command 031" + }, + { + "id": "cmd-bulk-032", + "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": "Test Command 032" + }, + { + "id": "cmd-bulk-033", + "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": "Test Command 033" + }, + { + "id": "cmd-bulk-034", + "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": "Test Command 034" + }, + { + "id": "cmd-bulk-035", + "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": "Test Command 035" + }, + { + "id": "cmd-bulk-036", + "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": "Test Command 036" + }, + { + "id": "cmd-bulk-037", + "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": "Test Command 037" + }, + { + "id": "cmd-bulk-038", + "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": "Test Command 038" + }, + { + "id": "cmd-bulk-039", + "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": "Test Command 039" + }, + { + "id": "cmd-bulk-040", + "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": "Test Command 040" + }, + { + "id": "cmd-bulk-041", + "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": "Test Command 041" + }, + { + "id": "cmd-bulk-042", + "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": "Test Command 042" + }, + { + "id": "cmd-bulk-043", + "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": "Test Command 043" + }, + { + "id": "cmd-bulk-044", + "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": "Test Command 044" + }, + { + "id": "cmd-bulk-045", + "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": "Test Command 045" + }, + { + "id": "cmd-bulk-046", + "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": "Test Command 046" + }, + { + "id": "cmd-bulk-047", + "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": "Test Command 047" + }, + { + "id": "cmd-bulk-048", + "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": "Test Command 048" + }, + { + "id": "cmd-bulk-049", + "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": "Test Command 049" + }, + { + "id": "cmd-bulk-050", + "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": "Test Command 050" + }, + { + "id": "cmd-bulk-051", + "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": "Test Command 051" + }, + { + "id": "cmd-bulk-052", + "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": "Test Command 052" + }, + { + "id": "cmd-bulk-053", + "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": "Test Command 053" + }, + { + "id": "cmd-bulk-054", + "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": "Test Command 054" + }, + { + "id": "cmd-bulk-055", + "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": "Test Command 055" + }, + { + "id": "cmd-bulk-056", + "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": "Test Command 056" + }, + { + "id": "cmd-bulk-057", + "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": "Test Command 057" + }, + { + "id": "cmd-bulk-058", + "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": "Test Command 058" + }, + { + "id": "cmd-bulk-059", + "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": "Test Command 059" + }, + { + "id": "cmd-bulk-060", + "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": "Test Command 060" + }, + { + "id": "cmd-bulk-061", + "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": "Test Command 061" + }, + { + "id": "cmd-bulk-062", + "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": "Test Command 062" + }, + { + "id": "cmd-bulk-063", + "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": "Test Command 063" + }, + { + "id": "cmd-bulk-064", + "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": "Test Command 064" + }, + { + "id": "cmd-bulk-065", + "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": "Test Command 065" + }, + { + "id": "cmd-bulk-066", + "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": "Test Command 066" + }, + { + "id": "cmd-bulk-067", + "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": "Test Command 067" + }, + { + "id": "cmd-bulk-068", + "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": "Test Command 068" + }, + { + "id": "cmd-bulk-069", + "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": "Test Command 069" + }, + { + "id": "cmd-bulk-070", + "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": "Test Command 070" + }, + { + "id": "cmd-bulk-071", + "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": "Test Command 071" + }, + { + "id": "cmd-bulk-072", + "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": "Test Command 072" + }, + { + "id": "cmd-bulk-073", + "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": "Test Command 073" + }, + { + "id": "cmd-bulk-074", + "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": "Test Command 074" + }, + { + "id": "cmd-bulk-075", + "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": "Test Command 075" + }, + { + "id": "cmd-bulk-076", + "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": "Test Command 076" + }, + { + "id": "cmd-bulk-077", + "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": "Test Command 077" + }, + { + "id": "cmd-bulk-078", + "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": "Test Command 078" + }, + { + "id": "cmd-bulk-079", + "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": "Test Command 079" + }, + { + "id": "cmd-bulk-080", + "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": "Test Command 080" + }, + { + "id": "cmd-bulk-081", + "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": "Test Command 081" + }, + { + "id": "cmd-bulk-082", + "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": "Test Command 082" + }, + { + "id": "cmd-bulk-083", + "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": "Test Command 083" + }, + { + "id": "cmd-bulk-084", + "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": "Test Command 084" + }, + { + "id": "cmd-bulk-085", + "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": "Test Command 085" + }, + { + "id": "cmd-bulk-086", + "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": "Test Command 086" + }, + { + "id": "cmd-bulk-087", + "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": "Test Command 087" + }, + { + "id": "cmd-bulk-088", + "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": "Test Command 088" + }, + { + "id": "cmd-bulk-089", + "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": "Test Command 089" + }, + { + "id": "cmd-bulk-090", + "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": "Test Command 090" + }, + { + "id": "cmd-bulk-091", + "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": "Test Command 091" + }, + { + "id": "cmd-bulk-092", + "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": "Test Command 092" + }, + { + "id": "cmd-bulk-093", + "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": "Test Command 093" + }, + { + "id": "cmd-bulk-094", + "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": "Test Command 094" + }, + { + "id": "cmd-bulk-095", + "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": "Test Command 095" + }, + { + "id": "cmd-bulk-096", + "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": "Test Command 096" + }, + { + "id": "cmd-bulk-097", + "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": "Test Command 097" + }, + { + "id": "cmd-bulk-098", + "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": "Test Command 098" + }, + { + "id": "cmd-bulk-099", + "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": "Test Command 099" + }, + { + "id": "cmd-bulk-100", + "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": "Test Command 100" + }, + { + "id": "cmd-bulk-101", + "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": "Test Command 101" + } + ], + "folders": [], + "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": [] + }, + "startupMethod": { + "method": "textSelection" + }, + "style": "horizontal", + "userStyles": [ + { + "name": "popup-delay", + "value": 250 + }, + { + "name": "popup-duration", + "value": 150 + }, + { + "name": "padding-scale", + "value": "1.5" + } + ], + "windowOption": { + "popupAutoCloseDelay": 0, + "sidePanelAutoHide": false + } +} diff --git a/packages/extension/e2e/data/test-settings-link-preview.json b/packages/extension/e2e/data/test-settings-link-preview.json new file mode 100644 index 00000000..4de21f54 --- /dev/null +++ b/packages/extension/e2e/data/test-settings-link-preview.json @@ -0,0 +1,60 @@ +{ + "commands": [ + { + "id": "$$drag-1", + "openMode": "previewPopup", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "Link Preview" + } + ], + "folders": [], + "linkCommand": { + "enabled": "Enable", + "openMode": "previewSidePanel", + "showIndicator": true, + "sidePanelAutoHide": true, + "startupMethod": { + "keyboardParam": "Shift", + "leftClickHoldParam": 200, + "method": "keyboard", + "threshold": 50 + } + }, + "pageRules": [], + "popupPlacement": { + "align": "start", + "alignOffset": 0, + "side": "top", + "sideOffset": 0 + }, + "settingVersion": "0.16.0", + "shortcuts": { + "shortcuts": [] + }, + "startupMethod": { + "method": "textSelection" + }, + "style": "horizontal", + "userStyles": [ + { + "name": "popup-delay", + "value": 250 + }, + { + "name": "popup-duration", + "value": 150 + }, + { + "name": "padding-scale", + "value": "1.5" + } + ], + "windowOption": { + "popupAutoCloseDelay": 0, + "sidePanelAutoHide": false + } +} diff --git a/packages/extension/e2e/data/test-settings.json b/packages/extension/e2e/data/test-settings.json index 9bf3de70..63aa379b 100644 --- a/packages/extension/e2e/data/test-settings.json +++ b/packages/extension/e2e/data/test-settings.json @@ -1,5 +1,15 @@ { "commands": [ + { + "id": "$$drag-1", + "openMode": "previewPopup", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "Link Preview" + }, { "iconUrl": "https://ujiro99.github.io/selection-command/favicon.ico", "id": "13b4e831-7fac-4b72-a1ae-c82655b3819e", @@ -262,14 +272,164 @@ "title": "en to ja" }, { - "id": "$$drag-1", - "openMode": "previewPopup", + "id": "cmd-test-window", + "openMode": "window", + "openModeSecondary": "tab", + "parentFolderId": "RootFolder", "popupOption": { "height": 700, "width": 600 }, "revision": 0, - "title": "Link Preview" + "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s", + "spaceEncoding": "plus", + "title": "テストページ (Window)" + }, + { + "id": "cmd-test-sidepanel", + "openMode": "sidePanel", + "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": "テストページ (SidePanel)" + }, + { + "id": "cmd-test-copy", + "openMode": "copy", + "parentFolderId": "RootFolder", + "revision": 0, + "title": "テキストコピー" + }, + { + "id": "cmd-test-linkpopup", + "openMode": "linkPopup", + "parentFolderId": "RootFolder", + "revision": 0, + "title": "リンクポップアップ" + }, + { + "id": "cmd-test-deepl", + "openMode": "tab", + "openModeSecondary": "tab", + "parentFolderId": "lang-folder-001", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "searchUrl": "https://www.deepl.com/translator#auto/ja/%s", + "spaceEncoding": "plus", + "title": "DeepL" + }, + { + "aiPromptOption": { + "openMode": "sidePanel", + "prompt": "以下のテキストについて説明してください。\n{{Clipboard}}", + "serviceId": "gemini" + }, + "id": "cmd-test-ai-clipboard", + "openMode": "aiPrompt", + "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae", + "popupOption": { + "height": 700, + "width": 600 + }, + "revision": 0, + "title": "クリップボード展開テスト" + }, + { + "id": "cmd-test-pa-tab", + "openMode": "pageAction", + "pageActionOption": { + "openMode": "tab", + "startUrl": "https://web-toolbox.dev/tools/character-counter", + "steps": [ + { + "delayMs": 0, + "id": "pa-tab-s1", + "param": { + "label": "Start", + "type": "start", + "url": "https://web-toolbox.dev/tools/character-counter" + }, + "skipRenderWait": false + }, + { + "delayMs": 500, + "id": "pa-tab-s2", + "param": { + "label": "文字入力", + "selector": "//textarea", + "selectorType": "xpath", + "type": "input", + "value": "{{SelectedText}}" + }, + "skipRenderWait": false + }, + { + "delayMs": 0, + "id": "pa-tab-s3", + "param": { + "label": "End", + "type": "end" + }, + "skipRenderWait": false + } + ] + }, + "parentFolderId": "0f2167ab-2e1b-4972-954c-71eec058ab14", + "revision": 0, + "title": "Character Counter (Tab)" + }, + { + "id": "cmd-test-pa-bgtab", + "openMode": "pageAction", + "pageActionOption": { + "openMode": "backgroundTab", + "startUrl": "https://web-toolbox.dev/tools/character-counter", + "steps": [ + { + "delayMs": 0, + "id": "pa-bgtab-s1", + "param": { + "label": "Start", + "type": "start", + "url": "https://web-toolbox.dev/tools/character-counter" + }, + "skipRenderWait": false + }, + { + "delayMs": 500, + "id": "pa-bgtab-s2", + "param": { + "label": "文字入力", + "selector": "//textarea", + "selectorType": "xpath", + "type": "input", + "value": "{{SelectedText}}" + }, + "skipRenderWait": false + }, + { + "delayMs": 0, + "id": "pa-bgtab-s3", + "param": { + "label": "End", + "type": "end" + }, + "skipRenderWait": false + } + ] + }, + "parentFolderId": "0f2167ab-2e1b-4972-954c-71eec058ab14", + "revision": 0, + "title": "Character Counter (BG Tab)" } ], "folders": [ @@ -305,6 +465,12 @@ "iconUrl": "", "id": "01710cf1-ec8b-497f-8d1f-9cb716567bc4", "title": "Work" + }, + { + "iconUrl": "", + "id": "lang-folder-001", + "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae", + "title": "Lang" } ], "linkCommand": { @@ -376,4 +542,4 @@ "popupAutoCloseDelay": 0, "sidePanelAutoHide": false } -} \ No newline at end of file +} diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index 82aa64c8..a9f5b13d 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -81,19 +81,38 @@ export const test = base.extend({ } await use(async () => { const result = await serviceWorker.evaluate(async () => { - const { 5: metaData } = await chrome.storage.sync.get<{ + // 1. Load commands from sync storage. + const { 5: syncMetaData } = await chrome.storage.sync.get<{ "5": CommandMetadata }>("5") - const count = metaData.count + const syncCount = syncMetaData.count const CMD_PREFIX = "cmd-" const cmdSyncKey = (idx: number): string => `${CMD_PREFIX}${idx}` - const keys = Array.from({ length: count }, (_, i) => cmdSyncKey(i)) - const result = await chrome.storage.sync.get(keys) - - return keys - .map((key) => result[key] as Command) - .filter((cmd) => cmd != null) + const syncKeys = Array.from({ length: syncCount }, (_, i) => + cmdSyncKey(i), + ) + const syncResult = await chrome.storage.sync.get(syncKeys) + const syncCommands = syncKeys.map((key) => syncResult[key] as Command) + + // 2. Load commands from local storage + const LOCAL_COMMAND_METADATA = "localCommandMetadata" + const { [LOCAL_COMMAND_METADATA]: localMetaData } = + await chrome.storage.local.get<{ + [LOCAL_COMMAND_METADATA]: CommandMetadata + }>(LOCAL_COMMAND_METADATA) + const localCount = localMetaData.count + + const cmdLocalKey = (idx: number): string => `${CMD_PREFIX}local-${idx}` + const localKeys = Array.from({ length: localCount }, (_, i) => + cmdLocalKey(i), + ) + const localResult = await chrome.storage.sync.get(syncKeys) + const localCommands = localKeys.map( + (key) => localResult[key] as Command, + ) + + return [...syncCommands, ...localCommands].filter((cmd) => cmd != null) }) return result }) diff --git a/packages/extension/e2e/menu-style.spec.ts b/packages/extension/e2e/menu-style.spec.ts new file mode 100644 index 00000000..b26a23b8 --- /dev/null +++ b/packages/extension/e2e/menu-style.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "./fixtures" +import { TestPage } from "./pages/TestPage" +import { STYLE } from "../src/const" + +test.describe("Menu Style", () => { + /** + * E2E-15: Verify that the popup menu is displayed in horizontal (row) layout. + */ + test("E2E-15: popup menu is displayed horizontally", async ({ page }) => { + // test-settings.json sets style: "horizontal" by default + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + const buttons = await menubar.locator("button").all() + if (buttons.length >= 2) { + const box1 = await buttons[0].boundingBox() + const box2 = await buttons[1].boundingBox() + expect(box1).toBeTruthy() + expect(box2).toBeTruthy() + // In horizontal mode: adjacent buttons are at similar vertical positions + const yDiff = Math.abs(box2!.y - box1!.y) + expect(yDiff).toBeLessThan(box1!.height) + } + }) + + /** + * E2E-16: Verify that the popup menu is displayed in vertical (column) layout. + */ + test("E2E-16: popup menu is displayed vertically", async ({ + setUserSettings, + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + + // Override the default horizontal style for this test + // Since the configuration values won’t be applied if executed immediately, + // perform the operation after displaying the test page. + await setUserSettings({ style: STYLE.VERTICAL }) + + await testPage.selectText() + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + const buttons = await menubar.locator("button").all() + if (buttons.length >= 2) { + const box1 = await buttons[0].boundingBox() + const box2 = await buttons[1].boundingBox() + expect(box1).toBeTruthy() + expect(box2).toBeTruthy() + // In vertical mode: second button is below the first + expect(box2!.y).toBeGreaterThan(box1!.y + box1!.height - 5) + } + }) +}) diff --git a/packages/extension/e2e/pages/OptionsPage.ts b/packages/extension/e2e/pages/OptionsPage.ts index d6dbb8aa..31a019a4 100644 --- a/packages/extension/e2e/pages/OptionsPage.ts +++ b/packages/extension/e2e/pages/OptionsPage.ts @@ -1,4 +1,5 @@ import path from "path" +import fs from "fs" import { Page, type BrowserContext } from "@playwright/test" @@ -10,9 +11,6 @@ function sleep(msec: number): Promise { return new Promise((resolve) => setTimeout(resolve, msec)) } -// Load test settings from the JSON file. -// This is used to provide consistent test data for the importSettings method. -import testSettings from "../data/test-settings.json" with { type: "json" } const __dirname = path.dirname(fileURLToPath(import.meta.url)) const TEST_SETTINGS_PATH = path.join(__dirname, "../data/test-settings.json") @@ -53,16 +51,12 @@ export class OptionsPage { } /** - * 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. + * Import settings from a given file path. + * Defaults to the standard test-settings.json. */ - async importSettings(): Promise { + async importSettings( + settingsPath: string = TEST_SETTINGS_PATH, + ): Promise { if (!this.page) { await this.open() if (!this.page) { @@ -70,6 +64,11 @@ export class OptionsPage { } } + // Load the settings file to know the expected command count + const rawJson = fs.readFileSync(settingsPath, "utf-8") + const settingsJson = JSON.parse(rawJson) + const expectedCommandCount: number = settingsJson.commands?.length ?? 0 + // Open the import dialog await this.page.locator(`[data-testid="${TEST_IDS.importButton}"]`).click() @@ -77,7 +76,7 @@ export class OptionsPage { const fileInput = this.page.locator( `[data-testid="${TEST_IDS.importFileInput}"]`, ) - await fileInput.setInputFiles(TEST_SETTINGS_PATH) + await fileInput.setInputFiles(settingsPath) // Wait for the file to be read and OK button to be enabled const okButton = this.page.locator( @@ -108,13 +107,61 @@ export class OptionsPage { commands = await this.getCommands() timeout -= interval } while ( - (commands == null || commands.length !== testSettings.commands.length) && + (commands == null || commands.length !== expectedCommandCount) && timeout > 0 ) - if (commands == null || commands.length !== testSettings.commands.length) { - console.error("Failed to import settings", commands?.length) + if (commands == null || commands.length !== expectedCommandCount) { + console.error( + "Failed to import settings", + commands?.length, + "expected:", + expectedCommandCount, + ) throw new Error("Failed to import settings") } } + + /** + * Export current settings and return the downloaded file content as a string. + */ + async exportSettings(): Promise { + if (!this.page) { + await this.open() + if (!this.page) { + throw new Error("Failed to open options page") + } + } + + const downloadPromise = this.page.waitForEvent("download") + await this.page.locator(`[data-testid="${TEST_IDS.exportButton}"]`).click() + const download = await downloadPromise + const filePath = await download.path() + if (!filePath) throw new Error("Download path is null") + return fs.readFileSync(filePath, "utf-8") + } + + /** + * Reset settings to defaults via the Reset button and confirm the dialog. + */ + async resetSettings(): Promise { + if (!this.page) { + await this.open() + if (!this.page) { + throw new Error("Failed to open options page") + } + } + + await this.page.locator(`[data-testid="${TEST_IDS.resetButton}"]`).click() + + // Wait for the confirm dialog and click OK + const okButton = this.page.locator( + `[data-testid="${TEST_IDS.optionDialogOk}"]`, + ) + await okButton.waitFor({ state: "visible", timeout: 5000 }) + const reloadPromise = this.page.waitForLoadState("domcontentloaded") + await okButton.click() + await reloadPromise + await sleep(500) + } } diff --git a/packages/extension/e2e/settings.spec.ts b/packages/extension/e2e/settings.spec.ts new file mode 100644 index 00000000..80f21735 --- /dev/null +++ b/packages/extension/e2e/settings.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from "./fixtures" +import { OptionsPage } from "./pages/OptionsPage" +import path from "path" +import { fileURLToPath } from "url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const LINK_PREVIEW_SETTINGS_PATH = path.join( + __dirname, + "data/test-settings-link-preview.json", +) +const HUNDRED_COMMANDS_SETTINGS_PATH = path.join( + __dirname, + "data/test-settings-100-commands.json", +) + +test.describe("Settings Page", () => { + /** + * E2E-80: Verify that importing a settings file with popup commands succeeds. + */ + test("E2E-80: import settings with popup commands", async ({ + context, + extensionId, + getCommands, + }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + // importSettings validates command count; if this succeeds, import worked + await optionsPage.importSettings() + await optionsPage.close() + + const commands = await getCommands() + expect(commands).not.toBeNull() + expect(commands.length).toBeGreaterThan(0) + + // Verify at least one popup command was imported + const hasPopupCommand = commands.some((cmd) => cmd.openMode === "popup") + expect(hasPopupCommand).toBe(true) + }) + + /** + * E2E-81: Verify that importing a settings file with link preview configuration succeeds. + */ + test("E2E-81: import settings with link preview configuration", async ({ + context, + extensionId, + getCommands, + getUserSettings, + }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.importSettings(LINK_PREVIEW_SETTINGS_PATH) + await optionsPage.close() + + const userSettings = await getUserSettings() + expect(userSettings.linkCommand).toBeDefined() + expect(userSettings.linkCommand.enabled).toBe("Enable") + expect(userSettings.linkCommand.openMode).toBe("previewSidePanel") + expect(userSettings.linkCommand.sidePanelAutoHide).toBe(true) + expect(userSettings.linkCommand.startupMethod.method).toBe("keyboard") + }) + + /** + * E2E-82: Verify that importing 100+ commands succeeds without timeout or limit errors. + */ + test("E2E-82: import settings with 100+ commands", async ({ + context, + extensionId, + getCommands, + }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.importSettings(HUNDRED_COMMANDS_SETTINGS_PATH) + await optionsPage.close() + + const commands = await getCommands() + expect(commands).not.toBeNull() + expect(commands.length).toBe(102) + }) + + /** + * E2E-83: Verify that exporting settings produces a valid JSON file with current settings. + */ + test("E2E-83: export settings produces a valid JSON file", async ({ + context, + extensionId, + getCommands, + }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.importSettings() + + const exportedContent = await optionsPage.exportSettings() + await optionsPage.close() + + // Verify the exported content is valid JSON + const exportedSettings = JSON.parse(exportedContent) + expect(exportedSettings).toBeDefined() + expect(exportedSettings.commands).toBeDefined() + expect(Array.isArray(exportedSettings.commands)).toBe(true) + expect(exportedSettings.commands.length).toBeGreaterThan(0) + }) + + /** + * E2E-84: Verify that resetting settings restores default values. + */ + test("E2E-84: reset settings restores defaults", async ({ + context, + extensionId, + getCommands, + }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + + // Import non-default settings first + await optionsPage.importSettings() + const importedCommands = await getCommands() + expect(importedCommands.length).toBeGreaterThan(0) + + // Reset settings + await optionsPage.resetSettings() + await optionsPage.close() + + // After reset, commands should be different from the imported ones + // (defaults are restored) + const resetCommands = await getCommands() + expect(resetCommands).not.toBeNull() + // Default command count is less than the test settings count + expect(resetCommands.length).not.toBe(importedCommands.length) + }) +}) diff --git a/packages/extension/src/components/option/ImportExport.tsx b/packages/extension/src/components/option/ImportExport.tsx index ebf6d57f..e0b75fc9 100644 --- a/packages/extension/src/components/option/ImportExport.tsx +++ b/packages/extension/src/components/option/ImportExport.tsx @@ -264,7 +264,7 @@ export function ImportExport() { const handleImportClose = (ret: boolean) => { if (ret && importJson != null) { - ; (async () => { + ;(async () => { const { commandExecutionCount = 0, hasShownReviewRequest = false, @@ -290,7 +290,7 @@ export function ImportExport() { const handleRestoreClose = (ret: boolean) => { if (ret) { - ; (async () => { + ;(async () => { try { let backupCommands: any[] = [] @@ -371,7 +371,12 @@ export function ImportExport() { {t("Option_Import")} - @@ -385,8 +390,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") } @@ -394,7 +399,12 @@ export function ImportExport() { {t("Option_RestoreFromBackup")} - diff --git a/packages/extension/src/content_script.tsx b/packages/extension/src/content_script.tsx index 112dfcdb..4cc8c39c 100644 --- a/packages/extension/src/content_script.tsx +++ b/packages/extension/src/content_script.tsx @@ -1,7 +1,6 @@ import { createRoot } from "react-dom/client" import { APP_ID, isDebug, isE2E } from "./const" import { App } from "./components/App" -import icons from "./icons.svg?raw" import { initSentry, Sentry, ErrorBoundary } from "@/lib/sentry" import "@/services/connection" @@ -16,7 +15,6 @@ try { document.body.insertAdjacentElement("afterend", rootDom) const mode = isDebug || isE2E ? "open" : "closed" // 'open' for debugging and e2e const shadow = rootDom.attachShadow({ mode }) - shadow.innerHTML = icons const root = createRoot(shadow) root.render( @@ -39,9 +37,10 @@ try { }) } + console.log("Content script initialized successfully", { isDebug, isE2E }) if (!isDebug) { // Putting styles into ShadowDom - insertCss(shadow, "/assets/icons.css") + insertCss(shadow, "/assets/components.css") insertCss(shadow, "/assets/content_script.css") } diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts index 4c7dd49c..a5aa3e9d 100644 --- a/packages/extension/src/testIds.ts +++ b/packages/extension/src/testIds.ts @@ -4,4 +4,6 @@ export const TEST_IDS = { importFileInput: "import-file-input", optionDialogOk: "option-dialog-ok", optionDialogCancel: "option-dialog-cancel", + exportButton: "export-button", + resetButton: "reset-button", } diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 3dc1bfe9..372ee06a 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -123,20 +123,20 @@ export default defineConfig(({ mode }) => { pure: mode === "production" ? [ - "console.log", - "console.debug", - "console.info", - "console.trace", - "console.dir", - "console.count", - "console.countReset", - "console.group", - "console.groupCollapsed", - "console.groupEnd", - "console.time", - "console.timeEnd", - "console.timeLog", - ] + "console.log", + "console.debug", + "console.info", + "console.trace", + "console.dir", + "console.count", + "console.countReset", + "console.group", + "console.groupCollapsed", + "console.groupEnd", + "console.time", + "console.timeEnd", + "console.timeLog", + ] : [], }, build: { @@ -148,11 +148,13 @@ export default defineConfig(({ mode }) => { }, output: { assetFileNames: (assetInfo) => { - const keepNames = [ - "content_script.css", - "icons.css", - "command_hub.css", - ] + if ( + assetInfo.source != null && + assetInfo.source.toString().match(/^\._popup/) + ) { + return `assets/components.css` + } + const keepNames = ["content_script.css", "command_hub.css"] if ( assetInfo.names?.length > 0 && keepNames.includes(assetInfo.names[0]) From 2e8c44451e08c14bf812c10b60c68e311bf4f317 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 23 Mar 2026 15:17:05 +0900 Subject: [PATCH 04/38] Update: Completed implementation of e2e tests for startup methods. --- docs/test/e2e-test-spec.md | 931 +++++++++++++++++++++++ packages/extension/e2e/extension.spec.ts | 74 +- packages/extension/e2e/pages/TestPage.ts | 52 +- packages/extension/e2e/startup.spec.ts | 70 ++ 4 files changed, 1056 insertions(+), 71 deletions(-) create mode 100644 docs/test/e2e-test-spec.md create mode 100644 packages/extension/e2e/startup.spec.ts diff --git a/docs/test/e2e-test-spec.md b/docs/test/e2e-test-spec.md new file mode 100644 index 00000000..f3b4ed75 --- /dev/null +++ b/docs/test/e2e-test-spec.md @@ -0,0 +1,931 @@ +# E2Eテスト仕様書 + +## 概要 + +本仕様書は Selection Command 拡張機能の E2E テスト対象項目を定義したものです。 +自動テスト対象として選定された項目(◯)をカバーします。 + +テストフレームワーク: Playwright +テストページ: `https://ujiro99.github.io/selection-command/en/test` +設定ページ: `chrome-extension://{extensionId}/src/options_page.html` + +--- + +## テスト項目一覧 + +| テストID | 分類 | 機能1 | 機能2 | 実装状況 | +| -------- | ------------------ | ---------------------------- | ----------------------------- | -------- | +| E2E-10 | 起動方法 | テキスト選択 | - | 実装済 | +| E2E-11 | 起動方法 | キー入力 | Shift | 実装済 | +| E2E-12 | 起動方法 | 左クリック長押し | 150ms | 実装済 | +| E2E-15 | メニュースタイル | 横並び | - | 実装済 | +| E2E-16 | メニュースタイル | 縦並び | - | 実装済 | +| E2E-20 | 検索コマンド | OpenMode | Popup | 実装済 | +| E2E-21 | 検索コマンド | OpenMode | Tab | 未実装 | +| E2E-22 | 検索コマンド | OpenMode | Window | 未実装 | +| E2E-23 | 検索コマンド | OpenMode | SidePanel | 未実装 | +| E2E-24 | 検索コマンド | フォルダ格納 | none | 未実装 | +| E2E-25 | 検索コマンド | フォルダ格納 | Shop | 未実装 | +| E2E-26 | 検索コマンド | フォルダ格納 | AI → Lang | 未実装 | +| E2E-30 | 単機能コマンド | Copy Text | - | 未実装 | +| E2E-31 | 単機能コマンド | Link Popup | 複数リンクを開く | 未実装 | +| E2E-40 | PageActionコマンド | 記録 | クリック | 未実装 | +| E2E-41 | PageActionコマンド | 記録 | Enter | 未実装 | +| E2E-42 | PageActionコマンド | 記録 | テキスト入力(input) | 未実装 | +| E2E-43 | PageActionコマンド | 記録 | テキスト入力(contentEditable) | 未実装 | +| E2E-44 | PageActionコマンド | 記録 | スクロール | 未実装 | +| E2E-45 | PageActionコマンド | 再生 | Sakuraチェッカー | 未実装 | +| E2E-46 | PageActionコマンド | 再生(バックグラウンドタブ) | Sakuraチェッカー | 未実装 | +| E2E-50 | AiPromptコマンド | OpenMode | Popup | 未実装 | +| E2E-51 | AiPromptコマンド | OpenMode | SidePanel | 未実装 | +| E2E-52 | AiPromptコマンド | クリップボード | SidePanel | 未実装 | +| E2E-60 | リンクプレビュー | 起動方法 | Shiftキー + クリック | 未実装 | +| E2E-61 | リンクプレビュー | 起動方法 | ドラッグ | 未実装 | +| E2E-62 | リンクプレビュー | 起動方法 | 左クリック長押し | 未実装 | +| E2E-63 | リンクプレビュー | 画像DLリンクのプレビュー | - | 未実装 | +| E2E-64 | リンクプレビュー | Open Mode | Window | 未実装 | +| E2E-65 | リンクプレビュー | Open Mode | SidePanel | 未実装 | +| E2E-66 | リンクプレビュー | ドラッグ距離 | 50px | 未実装 | +| E2E-70 | ユーザースタイル | 余白サイズ | 1 | 未実装 | +| E2E-71 | ユーザースタイル | 余白サイズ | 1.5 | 未実装 | +| E2E-72 | ユーザースタイル | 背景色 | 設定 | 未実装 | +| E2E-73 | ユーザースタイル | 背景色 | 削除 | 未実装 | +| E2E-80 | 設定画面 | インポート | ポップアップ | 実装済 | +| E2E-81 | 設定画面 | インポート | リンクプレビュー | 実装済 | +| E2E-82 | 設定画面 | インポート | コマンド100件以上 | 実装済 | +| E2E-83 | 設定画面 | エクスポート | - | 実装済 | +| E2E-84 | 設定画面 | リセット | - | 実装済 | +| E2E-90 | ハブ | PageActionコマンド | インストール | 未実装 | +| E2E-91 | ハブ | 追加 | - | 未実装 | +| E2E-92 | ハブ | 削除 | - | 未実装 | + +--- + +## 詳細仕様 + +--- + +### 起動方法 + +--- + +#### E2E-10: テキスト選択によるポップアップ表示 + +**事前条件** + +- 拡張機能がインストールされている +- 起動方法がデフォルト設定(テキスト選択) + +**手順** + +1. テストページを開く +2. ページ内のテキストをダブルクリックして選択する + +**期待動作** + +- ポップアップメニューが表示される(`data-state="open"` が DOM に存在する) + +--- + +#### E2E-11: Shiftキー押下によるポップアップ表示 + +**事前条件** + +- 拡張機能がインストールされている +- 起動方法を `KEYBOARD / SHIFT` に設定済み + +**手順** + +1. テストページを開く +2. `startupMethod` を `{ method: STARTUP_METHOD.KEYBOARD, keyboardParam: KEYBOARD.SHIFT }` に設定する +3. テキストを選択する +4. `Shift` キーを押下する + +**期待動作** + +- ポップアップメニューが表示される +- ポップアップ表示遅延が `0` になる + +--- + +#### E2E-12: 左クリック長押し(150ms)によるポップアップ表示 + +**事前条件** + +- 拡張機能がインストールされている +- 起動方法を `左クリック長押し / 150ms` に設定済み + +**手順** + +1. テストページを開く +2. `startupMethod` を `{ method: STARTUP_METHOD.LONG_PRESS, longPressParam: 150 }` に設定する +3. テキスト要素を 150ms 以上左クリック長押しする + +**期待動作** + +- ポップアップメニューが表示される + +--- + +### メニュースタイル + +--- + +#### E2E-15: メニュー横並び表示 + +**事前条件** + +- 拡張機能がインストールされている +- `popupPlacement` を横並び(`horizontal`)に設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ポップアップメニューが横並びで表示される(メニューバーの flex 方向が `row`、またはボタンが横一列に配置される) + +--- + +#### E2E-16: メニュー縦並び表示 + +**事前条件** + +- 拡張機能がインストールされている +- `popupPlacement` を縦並び(`vertical`)に設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ポップアップメニューが縦並びで表示される(ボタンが縦一列に配置される) + +--- + +### 検索コマンド + +--- + +#### E2E-20: 検索コマンド / OpenMode: Popup + +**事前条件** + +- テスト設定(`test-settings.json`)がインポートされている +- 1番目のコマンドが OpenMode: `Popup` のテスト用検索コマンドである + +**手順** + +1. 設定ページでテスト設定をインポートする +2. テストページを開く +3. テキストを選択してポップアップメニューを表示する +4. ポップアップメニューの最初のボタンをクリックする + +**期待動作** + +- `WindowType.popup` の新しいウィンドウが開く +- 開いたウィンドウの URL に選択テキストが含まれる(`?k=` パラメータなど) + +--- + +#### E2E-21: 検索コマンド / OpenMode: Tab + +**事前条件** + +- OpenMode が `Tab` のコマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. OpenMode: `Tab` のコマンドをクリックする + +**期待動作** + +- 新しいタブが開く(ポップアップウィンドウではなく通常タブ) +- 開いたタブの URL に選択テキストが含まれる + +--- + +#### E2E-22: 検索コマンド / OpenMode: Window + +**事前条件** + +- OpenMode が `Window` のコマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. OpenMode: `Window` のコマンドをクリックする + +**期待動作** + +- 通常のブラウザウィンドウ(`WindowType.window`)が新たに開く +- 開いたウィンドウの URL に選択テキストが含まれる + +--- + +#### E2E-23: 検索コマンド / OpenMode: SidePanel + +**事前条件** + +- OpenMode が `SidePanel` のコマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. OpenMode: `SidePanel` のコマンドをクリックする + +**期待動作** + +- 現在タブのサイドパネルが開く +- サイドパネル内に選択テキストを使った検索結果が表示される + +--- + +#### E2E-24: 検索コマンド / フォルダ格納: none(ルート直下) + +**事前条件** + +- フォルダに属さないコマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ポップアップメニューのルート直下にコマンドのボタンが表示される(フォルダアイコンを経由せず直接表示) + +--- + +#### E2E-25: 検索コマンド / フォルダ格納: Shop + +**事前条件** + +- `Shop` フォルダに格納されたコマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. ポップアップメニュー内の `Shop` フォルダをクリックする + +**期待動作** + +- `Shop` フォルダが展開される +- フォルダ内にコマンドが表示される + +--- + +#### E2E-26: 検索コマンド / フォルダ格納: AI → Lang(ネストフォルダ) + +**事前条件** + +- `AI` フォルダの中の `Lang` フォルダに格納されたコマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. `AI` フォルダをクリックして展開する +4. `Lang` サブフォルダをクリックして展開する + +**期待動作** + +- `Lang` フォルダ内にコマンドが表示される + +--- + +### 単機能コマンド + +--- + +#### E2E-30: Copy Text コマンド + +**事前条件** + +- `Copy Text` コマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. `Copy Text` コマンドをクリックする + +**期待動作** + +- 選択テキストがクリップボードにコピーされる +- クリップボードの内容が選択テキストと一致する + +--- + +#### E2E-31: Link Popup コマンド(複数リンクを開く) + +**事前条件** + +- `Link Popup` コマンドが設定済み +- テストページに複数のリンクが含まれる + +**手順** + +1. テストページを開く +2. 複数のリンクテキストを含む範囲を選択してポップアップメニューを表示する +3. `Link Popup` コマンドをクリックする + +**期待動作** + +- 選択範囲内の各リンクがそれぞれ個別の `WindowType.popup` ウィンドウで開く + +--- + +### PageActionコマンド + +--- + +#### E2E-40: PageAction 記録 / クリック + +**事前条件** + +- PageAction コマンドが設定済み +- テストページに PageAction で操作可能なクリック要素が存在する + +**手順** + +1. テストページを開く +2. PageAction の記録を開始する +3. ページ内の要素をクリックする +4. 記録を終了する + +**期待動作** + +- クリック操作が PageAction のステップとして記録される(記録データに `click` アクションが含まれる) + +--- + +#### E2E-41: PageAction 記録 / Enter キー + +**事前条件** + +- PageAction コマンドが設定済み +- テストページにフォーカス可能な入力要素が存在する + +**手順** + +1. テストページを開く +2. PageAction の記録を開始する +3. 入力要素にフォーカスして Enter キーを押下する +4. 記録を終了する + +**期待動作** + +- Enter キー押下が PageAction のステップとして記録される(記録データに `key: Enter` アクションが含まれる) + +--- + +#### E2E-42: PageAction 記録 / テキスト入力(input) + +**事前条件** + +- PageAction コマンドが設定済み +- テストページに `` 要素が存在する + +**手順** + +1. テストページを開く +2. PageAction の記録を開始する +3. `` 要素にテキストを入力する +4. 記録を終了する + +**期待動作** + +- テキスト入力操作が PageAction のステップとして記録される(記録データに `input` タイプのアクションと入力テキストが含まれる) + +--- + +#### E2E-43: PageAction 記録 / テキスト入力(contentEditable) + +**事前条件** + +- PageAction コマンドが設定済み +- テストページに `contentEditable` 要素が存在する + +**手順** + +1. テストページを開く +2. PageAction の記録を開始する +3. `contentEditable` 要素にテキストを入力する +4. 記録を終了する + +**期待動作** + +- テキスト入力操作が PageAction のステップとして記録される(記録データに `contentEditable` タイプのアクションと入力テキストが含まれる) + +--- + +#### E2E-44: PageAction 記録 / スクロール + +**事前条件** + +- PageAction コマンドが設定済み +- テストページがスクロール可能な長さを持つ + +**手順** + +1. テストページを開く +2. PageAction の記録を開始する +3. ページをスクロールする +4. 記録を終了する + +**期待動作** + +- スクロール操作が PageAction のステップとして記録される(記録データに `scroll` アクションとスクロール量が含まれる) + +--- + +#### E2E-45: PageAction 再生 / Sakuraチェッカー + +**事前条件** + +- Sakuraチェッカー用 PageAction コマンドが設定済み +- テストページが開かれている + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. Sakuraチェッカー PageAction コマンドをクリックする + +**期待動作** + +- Sakuraチェッカーのページが開く(新しいタブまたはポップアップ) +- 開いたページの URL にテストページの URL が含まれる + +--- + +#### E2E-46: PageAction 再生(バックグラウンドタブ)/ Sakuraチェッカー + +**事前条件** + +- バックグラウンドタブ実行設定の Sakuraチェッカー用 PageAction コマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. バックグラウンドタブ設定の Sakuraチェッカー PageAction コマンドをクリックする + +**期待動作** + +- Sakuraチェッカーのページがバックグラウンドタブとして開く(現在タブはテストページのまま) +- 開いたタブの URL にテストページの URL が含まれる + +--- + +### AiPromptコマンド + +--- + +#### E2E-50: AiPromptコマンド / OpenMode: Popup + +**事前条件** + +- OpenMode が `Popup` の AiPrompt コマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. AiPrompt コマンドをクリックする + +**期待動作** + +- `WindowType.popup` の新しいウィンドウが開く +- ウィンドウ内に AI サービスのページが表示される + +--- + +#### E2E-51: AiPromptコマンド / OpenMode: SidePanel + +**事前条件** + +- OpenMode が `SidePanel` の AiPrompt コマンドが設定済み + +**手順** + +1. テストページを開く +2. テキストを選択してポップアップメニューを表示する +3. AiPrompt コマンドをクリックする + +**期待動作** + +- 現在タブのサイドパネルが開く +- サイドパネル内に AI サービスのページが表示される + +--- + +#### E2E-52: AiPromptコマンド / クリップボード展開(SidePanel) + +**事前条件** + +- クリップボード内容を展開するプロンプト(`{clipboard}` プレースホルダー使用)の AiPrompt コマンドが設定済み +- OpenMode が `SidePanel` +- クリップボードに任意のテキストが格納されている + +**手順** + +1. クリップボードにテキストをセットする +2. テストページを開く +3. テキストを選択してポップアップメニューを表示する +4. AiPrompt コマンドをクリックする + +**期待動作** + +- サイドパネルが開く +- URL に埋め込まれるプロンプト中の `{clipboard}` がクリップボードの内容に展開された状態で AI サービスに渡される + +--- + +### リンクプレビュー + +--- + +#### E2E-60: リンクプレビュー起動 / Shiftキー + クリック + +**事前条件** + +- リンクプレビュー機能が有効 +- テストページにリンクが存在する + +**手順** + +1. テストページを開く +2. リンク要素を `Shift` キーを押しながらクリックする + +**期待動作** + +- `WindowType.popup` の新しいウィンドウが開く +- ウィンドウにリンク先の URL が読み込まれる + +--- + +#### E2E-61: リンクプレビュー起動 / ドラッグ + +**事前条件** + +- リンクプレビュー機能が有効 +- テストページにリンクが存在する + +**手順** + +1. テストページを開く +2. リンク要素をドラッグする(ドラッグ距離はプレビュー起動閾値以上) + +**期待動作** + +- `WindowType.popup` の新しいウィンドウが開く +- ウィンドウにリンク先の URL が読み込まれる + +--- + +#### E2E-62: リンクプレビュー起動 / 左クリック長押し + +**事前条件** + +- リンクプレビュー機能が有効 +- テストページにリンクが存在する + +**手順** + +1. テストページを開く +2. リンク要素を左クリックで長押しする(設定した長押し時間以上) + +**期待動作** + +- `WindowType.popup` の新しいウィンドウが開く +- ウィンドウにリンク先の URL が読み込まれる + +--- + +#### E2E-63: リンクプレビュー / 画像ダウンロードリンク + +**事前条件** + +- リンクプレビュー機能が有効 +- テストページに Unsplash 等の画像ダウンロードリンクが存在する + +**手順** + +1. テストページを開く(または `https://unsplash.com/s/photos/sample` に相当するリンクを含むページへ遷移) +2. 画像ダウンロードリンクをプレビュー操作(Shift+クリック / ドラッグ)する + +**期待動作** + +- `WindowType.popup` の新しいウィンドウが開く +- ウィンドウにリンク先ページが表示される(ダウンロードではなくプレビュー表示) + +--- + +#### E2E-64: リンクプレビュー / OpenMode: Window + +**事前条件** + +- リンクプレビューの OpenMode が `Window` に設定済み +- テストページにリンクが存在する + +**手順** + +1. テストページを開く +2. リンクをプレビュー操作する + +**期待動作** + +- `WindowType.window` の通常ブラウザウィンドウが開く +- ウィンドウにリンク先の URL が読み込まれる + +--- + +#### E2E-65: リンクプレビュー / OpenMode: SidePanel + +**事前条件** + +- リンクプレビューの OpenMode が `SidePanel` に設定済み +- テストページにリンクが存在する + +**手順** + +1. テストページを開く +2. リンクをプレビュー操作する + +**期待動作** + +- 現在タブのサイドパネルが開く +- サイドパネルにリンク先の URL が読み込まれる + +--- + +#### E2E-66: リンクプレビュー / ドラッグ距離 50px + +**事前条件** + +- リンクプレビュー機能が有効 +- ドラッグ起動距離が `50px` に設定済み +- テストページにリンクが存在する + +**手順** + +1. テストページを開く +2. リンク要素を 50px 以上ドラッグする + +**期待動作** + +- プレビューウィンドウが表示される + +**追加確認** + +- 49px 以下のドラッグではプレビューが表示されないこと + +--- + +### ユーザースタイル + +--- + +#### E2E-70: 余白サイズ変更(1) + +**事前条件** + +- ユーザースタイルの余白サイズが設定可能な状態 + +**手順** + +1. 設定ページで余白サイズを `1` に設定する +2. テストページを開く +3. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ポップアップメニューの余白サイズがデフォルト(1.5)より狭くなる +- メニューボタンの padding が CSS 変数で `1rem` ベースの値になっている + +--- + +#### E2E-71: 余白サイズ変更(1.5 デフォルト) + +**事前条件** + +- 余白サイズが `1` 以外の値に変更された状態 + +**手順** + +1. 設定ページで余白サイズを `1.5` に戻す +2. テストページを開く +3. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ポップアップメニューの余白サイズがデフォルト(1.5)に戻る + +--- + +#### E2E-72: 背景色の設定 + +**事前条件** + +- ユーザースタイルの背景色が設定可能な状態 + +**手順** + +1. 設定ページでポップアップメニューの背景色を任意の色(例: `#ff0000`)に設定する +2. テストページを開く +3. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ポップアップメニューの背景色が設定した色に変更される + +--- + +#### E2E-73: 背景色の削除 + +**事前条件** + +- 背景色が設定済みの状態 + +**手順** + +1. 設定ページで背景色の設定を削除する +2. テストページを開く +3. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ポップアップメニューの背景色がデフォルト(白)に戻る +- 背景色の CSS 変数が未設定になる + +--- + +### 設定画面 + +--- + +#### E2E-80: インポート / ポップアップ設定 + +**事前条件** + +- ポップアップコマンドを含む設定ファイル(JSON)が用意されている + +**手順** + +1. 設定ページを開く +2. インポートボタンをクリックする +3. ポップアップコマンドを含む設定ファイルを選択する +4. OK ボタンをクリックする + +**期待動作** + +- インポートが成功する +- 設定ページが再読み込みされる +- インポートしたポップアップコマンドが一覧に表示される + +--- + +#### E2E-81: インポート / リンクプレビュー設定 + +**事前条件** + +- リンクプレビュー設定を含む設定ファイル(JSON)が用意されている + +**手順** + +1. 設定ページを開く +2. インポートボタンをクリックして、リンクプレビュー設定を含むファイルをインポートする +3. OK をクリックする + +**期待動作** + +- リンクプレビュー設定が反映される +- 設定ページのリンクプレビューセクションにインポートした設定値が表示される + +--- + +#### E2E-82: インポート / コマンド100件以上 + +**事前条件** + +- 100件以上のコマンドを含む設定ファイル(JSON)が用意されている + +**手順** + +1. 設定ページを開く +2. 100件以上のコマンドを含む設定ファイルをインポートする +3. OK をクリックする + +**期待動作** + +- インポートが成功する(タイムアウトや件数上限エラーが発生しない) +- インポートしたコマンド件数がすべてストレージに保存される + +--- + +#### E2E-83: エクスポート + +**事前条件** + +- 設定が1件以上存在する + +**手順** + +1. 設定ページを開く +2. エクスポートボタンをクリックする + +**期待動作** + +- 設定ファイル(JSON)がダウンロードされる +- エクスポートされたファイルに現在の設定(コマンド、ユーザー設定等)が含まれる + +--- + +#### E2E-84: リセット + +**事前条件** + +- デフォルトとは異なる設定が保存されている + +**手順** + +1. 設定ページを開く +2. リセットボタンをクリックする +3. 確認ダイアログで OK をクリックする + +**期待動作** + +- 設定がデフォルト値にリセットされる +- コマンド一覧がデフォルトコマンドのみになる + +--- + +### ハブ + +--- + +#### E2E-90: ハブ / PageActionコマンド インストール + +**事前条件** + +- Selection Command Hub にアクセスできる +- Hub 上に PageAction コマンドが公開されている + +**手順** + +1. Hub ページを開く +2. PageAction コマンドのインストールボタンをクリックする +3. インストールを確認する + +**期待動作** + +- PageAction コマンドが拡張機能の設定に追加される +- 設定ページのコマンド一覧にインストールしたコマンドが表示される + +--- + +#### E2E-91: ハブ / コマンド追加 + +**事前条件** + +- Selection Command Hub にアクセスできる +- Hub 上にダウンロード可能なコマンドが存在する + +**手順** + +1. Hub ページを開く +2. 任意のコマンドのダウンロードボタンをクリックする + +**期待動作** + +- コマンドが拡張機能の設定に追加される +- Hub ページのダウンロードボタンが「追加済み」状態に変化する + +--- + +#### E2E-92: ハブ / コマンド削除 + +**事前条件** + +- Hub からコマンドが追加済みの状態(E2E-91 実施後) + +**手順** + +1. 設定ページで Hub から追加したコマンドを削除する +2. Hub ページを再度開く(またはリロードする) + +**期待動作** + +- 削除したコマンドに対応する Hub ページのダウンロードボタンが復活する(「追加済み」表示が解除される) diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index 9e503df7..bd5293be 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "./fixtures" import { TestPage } from "./pages/TestPage" -import { OptionsPage } from "./pages/OptionsPage" -import { STARTUP_METHOD, KEYBOARD } from "../src/const" +// import { OptionsPage } from "./pages/OptionsPage" +import { APP_ID } from "../src/const" /** * E2E-01: Verify that the extension content script is injected into the test page. @@ -10,71 +10,7 @@ import { STARTUP_METHOD, KEYBOARD } from "../src/const" test("E2E-01: extension is injected into the test page", async ({ page }) => { const testPage = new TestPage(page) await testPage.open() -}) - -/** - * E2E-02: Verify that the popup menu appears when text is selected on the page. - * Double-clicking on a word triggers text selection and shows the popup menu. - */ -test("E2E-10: popup menu appears on text selection", async ({ page }) => { - const testPage = new TestPage(page) - await testPage.open() - - await testPage.selectText() - - const menubar = await testPage.getMenuBar() - expect(menubar.isVisible()) -}) - -test("E2E-11: popup menu appears on text selection and press a ShiftKey", async ({ - setUserSettings, - page, -}) => { - // Arrange: Set the startup method to "keyboard". - const testPage = new TestPage(page) - await testPage.open() - - await setUserSettings({ - startupMethod: { - method: STARTUP_METHOD.KEYBOARD, - keyboardParam: KEYBOARD.SHIFT, - }, - }) - await testPage.selectText() - await page.keyboard.press(KEYBOARD.SHIFT) - - // Act: Set the startup method to "shortcut" and dispatch the keyboard shortcut. - const menubar = await testPage.getMenuBar() - - // Asert - expect(menubar.isVisible()) -}) - -test("E2E-20: executing a command from the popup menu performs search on test page in a popup window", async ({ - context, - extensionId, - getCommands, - page, -}) => { - // Import test settings to ensure the first menu item is a Testpage command. - const optionsPage = new OptionsPage(context, extensionId, getCommands) - 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("h2") - const menubar = await testPage.getMenuBar() - - // 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") - - // Assert - expect(popupPage.url()).toContain("?k=Browser") + const locator = page.locator(`#${APP_ID}`) + await locator.waitFor({ state: "attached" }) + expect(locator).toBeVisible() }) diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index 780db536..1be1727f 100644 --- a/packages/extension/e2e/pages/TestPage.ts +++ b/packages/extension/e2e/pages/TestPage.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test" +import { expect, type Page, type Locator } from "@playwright/test" import { TEST_IDS } from "@/testIds" const TEST_URL = "https://ujiro99.github.io/selection-command/en/test" @@ -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. @@ -91,6 +91,54 @@ export class TestPage { ) } + /** + * Simulate a left-click hold (long-press) without clearing the text selection. + * + * locator.click({ delay }) uses CDP Input.dispatchMouseEvent which causes + * Chrome (--headless=new) to fire selectionchange on mousedown and collapse + * the selection. This sets selectionText = "" → enable = false in + * useLeftClickHold → release() → clearTimeout, so the hold is never detected. + * + * window.dispatchEvent with a synthetic JS MouseEvent does NOT trigger the + * browser's built-in selection-clearing behavior, so the text selection is + * preserved through the entire hold period and the extension's timeout fires + * correctly. + */ + async leftClickHold(locator: Locator, holdMs: number): Promise { + const box = await locator.boundingBox() + if (!box) throw new Error("Element has no bounding box") + const x = box.x + box.width / 2 + const y = box.y + box.height / 2 + + await this.page.evaluate( + ({ x, y }) => { + window.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: x, + clientY: y, + }), + ) + }, + { x, y }, + ) + await this.page.waitForTimeout(holdMs) + await this.page.evaluate( + ({ x, y }) => { + window.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX: x, + clientY: y, + }), + ) + }, + { x, y }, + ) + } + async getMenuBar(): Promise> { return this.page.locator(`[data-testid="${TEST_IDS.menuBar}"]`) } diff --git a/packages/extension/e2e/startup.spec.ts b/packages/extension/e2e/startup.spec.ts new file mode 100644 index 00000000..2954025f --- /dev/null +++ b/packages/extension/e2e/startup.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "./fixtures" +import { TestPage } from "./pages/TestPage" +import { STARTUP_METHOD, KEYBOARD } from "../src/const" + +/** + * E2E-10: Verify that the popup menu appears when text is selected on the page. + * Double-clicking on a word triggers text selection and shows the popup menu. + */ +test("E2E-10: popup menu appears on text selection", async ({ page }) => { + const testPage = new TestPage(page) + await testPage.open() + + await testPage.selectText() + + const menubar = await testPage.getMenuBar() + expect(menubar.isVisible()) +}) + +test("E2E-11: popup menu appears on text selection and press a ShiftKey", async ({ + setUserSettings, + page, +}) => { + // Arrange: Set the startup method to "keyboard". + const testPage = new TestPage(page) + await testPage.open() + + await setUserSettings({ + startupMethod: { + method: STARTUP_METHOD.KEYBOARD, + keyboardParam: KEYBOARD.SHIFT, + }, + }) + await testPage.selectText() + await page.keyboard.press(KEYBOARD.SHIFT) + + // Act: Set the startup method to "shortcut" and dispatch the keyboard shortcut. + const menubar = await testPage.getMenuBar() + + // Asert + expect(menubar.isVisible()) +}) + +/** + * E2E-12: Verify that the popup menu appears when left-click is held for 150ms or longer. + */ +test("E2E-12: popup menu appears on left-click hold (150ms)", async ({ + setUserSettings, + page, +}) => { + const testPage = new TestPage(page) + await testPage.open() + + await setUserSettings({ + startupMethod: { + method: STARTUP_METHOD.LEFT_CLICK_HOLD, + leftClickHoldParam: 150, + }, + }) + + await testPage.selectText("h1, h2") + + // Long-press via synthetic MouseEvent to preserve text selection in headless mode. + // locator.click({ delay }) uses CDP which fires selectionchange on mousedown and + // clears selectionText → disables useLeftClickHold → cancels the hold timeout. + const locator = page.locator("h1, h2").first() + await testPage.leftClickHold(locator, 150 + 10) + + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() +}) From ff0046f88465c185c6e7bdb51ef3ca2f02781824 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 23 Mar 2026 16:45:18 +0900 Subject: [PATCH 05/38] Update: Add e2e tests for SearchCommand. --- docs/test/e2e-test-spec.md | 12 +- .../extension/e2e/data/test-settings.json | 2 +- packages/extension/e2e/search-command.spec.ts | 166 ++++++++++++++++++ .../extension/src/components/menu/Menu.tsx | 2 + .../src/components/menu/MenuItem.tsx | 4 +- 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 packages/extension/e2e/search-command.spec.ts diff --git a/docs/test/e2e-test-spec.md b/docs/test/e2e-test-spec.md index f3b4ed75..7a43231a 100644 --- a/docs/test/e2e-test-spec.md +++ b/docs/test/e2e-test-spec.md @@ -21,12 +21,12 @@ | E2E-15 | メニュースタイル | 横並び | - | 実装済 | | E2E-16 | メニュースタイル | 縦並び | - | 実装済 | | E2E-20 | 検索コマンド | OpenMode | Popup | 実装済 | -| E2E-21 | 検索コマンド | OpenMode | Tab | 未実装 | -| E2E-22 | 検索コマンド | OpenMode | Window | 未実装 | -| E2E-23 | 検索コマンド | OpenMode | SidePanel | 未実装 | -| E2E-24 | 検索コマンド | フォルダ格納 | none | 未実装 | -| E2E-25 | 検索コマンド | フォルダ格納 | Shop | 未実装 | -| E2E-26 | 検索コマンド | フォルダ格納 | AI → Lang | 未実装 | +| E2E-21 | 検索コマンド | OpenMode | Tab | 実装済 | +| E2E-22 | 検索コマンド | OpenMode | Window | 実装済 | +| E2E-23 | 検索コマンド | OpenMode | SidePanel | スキップ | +| E2E-24 | 検索コマンド | フォルダ格納 | none | 実装済 | +| E2E-25 | 検索コマンド | フォルダ格納 | Shop | 実装済 | +| E2E-26 | 検索コマンド | フォルダ格納 | AI → Lang | 実装済 | | E2E-30 | 単機能コマンド | Copy Text | - | 未実装 | | E2E-31 | 単機能コマンド | Link Popup | 複数リンクを開く | 未実装 | | E2E-40 | PageActionコマンド | 記録 | クリック | 未実装 | diff --git a/packages/extension/e2e/data/test-settings.json b/packages/extension/e2e/data/test-settings.json index 63aa379b..61afc298 100644 --- a/packages/extension/e2e/data/test-settings.json +++ b/packages/extension/e2e/data/test-settings.json @@ -261,7 +261,7 @@ "id": "9a3fca67-e618-5dd3-9ecd-9eb2d088041a", "openMode": "tab", "openModeSecondary": "tab", - "parentFolderId": "01710cf1-ec8b-497f-8d1f-9cb716567bc4", + "parentFolderId": "RootFolder", "popupOption": { "height": 700, "width": 600 diff --git a/packages/extension/e2e/search-command.spec.ts b/packages/extension/e2e/search-command.spec.ts new file mode 100644 index 00000000..3e9056cc --- /dev/null +++ b/packages/extension/e2e/search-command.spec.ts @@ -0,0 +1,166 @@ +import { test, expect } from "./fixtures" +import { TestPage } from "./pages/TestPage" +import { OptionsPage } from "./pages/OptionsPage" + +test.describe("Search Command", () => { + test.beforeEach(async ({ context, extensionId, getCommands }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.importSettings() + await optionsPage.close() + }) + + test("E2E-20: executing a command from the popup menu performs search on test page in a popup window", async ({ + context, + extensionId, + getCommands, + page, + }) => { + // Import test settings to ensure the first menu item is a Testpage command. + const optionsPage = new OptionsPage(context, extensionId, getCommands) + 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("h2") + const menubar = await testPage.getMenuBar() + + // 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("[role='menuitem']").first().click(), + ]) + await popupPage.waitForLoadState("domcontentloaded") + + // Assert + expect(popupPage.url()).toContain("?k=Browser") + }) + + /** + * E2E-21: Verify that a search command with OpenMode Tab opens a new tab. + */ + test("E2E-21: search command opens result in a new tab", async ({ + context, + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText("h2") + const menubar = await testPage.getMenuBar() + + const [newPage] = await Promise.all([ + context.waitForEvent("page"), + menubar.locator("[role='menuitem'][name='en to ja']").click(), + ]) + await newPage.waitForLoadState("domcontentloaded") + + // The new page should be a regular tab (not a popup with restricted dimensions) + expect(newPage.url()).toContain("translate.google") + }) + + /** + * E2E-22: Verify that a search command with OpenMode Window opens a new window. + */ + test("E2E-22: search command opens result in a new window", async ({ + context, + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText("h2") + const menubar = await testPage.getMenuBar() + + const [newPage] = await Promise.all([ + context.waitForEvent("page"), + menubar + .locator("[role='menuitem'][name='テストページ (Window)']") + .click(), + ]) + await newPage.waitForLoadState("domcontentloaded") + expect(newPage.url()).toContain("ujiro99.github.io/selection-command") + }) + + /** + * E2E-23: Verify that a search command with OpenMode SidePanel opens the side panel. + * NOTE: Verifying the Chrome side panel in headless Playwright is not straightforward. + * This test checks that clicking the SidePanel command does not throw an error. + */ + test.skip("E2E-23: search command opens result in side panel", async ({ + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText("h2") + const menubar = await testPage.getMenuBar() + await menubar + .locator("[role='menuitem'][name='テストページ (SidePanel)']") + .click() + // Verification of side panel opening is not reliably possible in headless Chrome + }) + + /** + * E2E-24: Verify that a command with no folder is displayed directly in the popup menu. + */ + test("E2E-24: root-level command is visible directly in the popup menu", async ({ + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + + // "テストページ検索" is at RootFolder — visible without opening any folder + await expect( + menubar.locator("[role='menuitem'][name='テストページ検索']"), + ).toBeVisible() + }) + + /** + * E2E-25: Verify that a command inside a folder appears after clicking the folder. + */ + test("E2E-25: command in a folder is visible after expanding the folder", async ({ + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + + // Open the Work folder + await menubar + .locator('[role="menuitem"][aria-haspopup="menu"]', { hasText: "Work" }) + .hover() + + // Drive and "en to ja" are inside Work folder + await expect(page.locator("[role='menuitem'][name='Drive']")).toBeVisible() + }) + + /** + * E2E-26: Verify that a command in a nested folder (AI → Lang) is visible after + * expanding both folders. + */ + test("E2E-26: command in a nested folder (AI → Lang) is visible", async ({ + page, + }) => { + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + + // Open the AI folder (icon-only, identified by role + title attribute) + await menubar + .locator('[role="menuitem"][aria-haspopup="menu"][title="AI"]') + .hover() + + // Open the Lang sub-folder + await page + .locator('[role="menuitem"][aria-haspopup="menu"]', { hasText: "Lang" }) + .hover() + + // DeepL is inside Lang folder + await expect(page.locator("[role='menuitem'][name='DeepL']")).toBeVisible() + }) +}) diff --git a/packages/extension/src/components/menu/Menu.tsx b/packages/extension/src/components/menu/Menu.tsx index b2270d87..2752322c 100644 --- a/packages/extension/src/components/menu/Menu.tsx +++ b/packages/extension/src/components/menu/Menu.tsx @@ -215,6 +215,8 @@ const MenuFolder = (props: { "pointer-events-none": inTransition, })} ref={anchorRef} + aria-haspopup="menu" + title={folder.title} {...onHover(onHoverTrigger, folder.id)} > {status === ExecState.NONE && ( - icon + )} {status === ExecState.EXECUTING && ( Date: Tue, 24 Mar 2026 10:15:26 +0900 Subject: [PATCH 06/38] Update: Add e2e tests for Single Function Command. --- docs/test/e2e-test-spec.md | 4 +- packages/extension/CLAUDE.md | 16 +++- .../extension/e2e/data/test-settings.json | 10 +- packages/extension/e2e/extension.spec.ts | 4 +- packages/extension/e2e/pages/TestPage.ts | 95 ++++++++++++++++++- packages/extension/e2e/single-command.spec.ts | 73 ++++++++++++++ 6 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 packages/extension/e2e/single-command.spec.ts diff --git a/docs/test/e2e-test-spec.md b/docs/test/e2e-test-spec.md index 7a43231a..ca176e46 100644 --- a/docs/test/e2e-test-spec.md +++ b/docs/test/e2e-test-spec.md @@ -27,8 +27,8 @@ | E2E-24 | 検索コマンド | フォルダ格納 | none | 実装済 | | E2E-25 | 検索コマンド | フォルダ格納 | Shop | 実装済 | | E2E-26 | 検索コマンド | フォルダ格納 | AI → Lang | 実装済 | -| E2E-30 | 単機能コマンド | Copy Text | - | 未実装 | -| E2E-31 | 単機能コマンド | Link Popup | 複数リンクを開く | 未実装 | +| E2E-30 | 単機能コマンド | Copy Text | - | 実装済 | +| E2E-31 | 単機能コマンド | Link Popup | 複数リンクを開く | 実装済 | | E2E-40 | PageActionコマンド | 記録 | クリック | 未実装 | | E2E-41 | PageActionコマンド | 記録 | Enter | 未実装 | | E2E-42 | PageActionコマンド | 記録 | テキスト入力(input) | 未実装 | diff --git a/packages/extension/CLAUDE.md b/packages/extension/CLAUDE.md index 31747a67..3563f836 100644 --- a/packages/extension/CLAUDE.md +++ b/packages/extension/CLAUDE.md @@ -16,6 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `yarn dev` - Viteを使用した開発モードの開始 - `yarn build` - 拡張機能のビルド(TypeScriptコンパイル + Viteビルドを実行) +- `yarn build:e2e` - E2Eテスト用にビルド(E2Eテスト実行前に必ずこれを実行して最新のビルドを生成すること) - `yarn lint` - ESLintを実行してコード品質をチェック - `yarn test` - Vitestを使用したテストの実行 - `yarn test:ui` - VitestのUIモードでテストを実行 @@ -70,6 +71,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **スタイリング**: CSS Modules + Tailwind CSS(ver.3) - **状態管理**: React hooks with Chrome extension storage APIs - **テスト**: Vitest with jsdom for unit/integration testing +- **e2eテスト**: Playwright for browser automation testing - **コード品質**: ESLint for code quality ### プロジェクト構造の注意事項 @@ -104,11 +106,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **テスト設計書**: 各機能のテストケースを明確に定義 - 機能ごとにテストケースを分類し、優先順位を付ける - 正常系、異常系、境界値テストを含める -- **ユニットテスト**: 各関数やコンポーネントの個別テスト - - テスト間の副作用を最小限に抑えるため、モックの使用は最低限にする - - Arrange-Act-Assertパターンを使用 - - 共通的なモックは `src/test/setup.ts` に配置 - **テストケースの名前**: 機能名と期待される動作を明示的に記述する - テストケース名の接頭辞は、機能名の略称と通し番号を使用(例: `SU-` for Storage Usage) - 後から追加した場合は、さらに記号を付けて区別(例: SU-01-a) - 例: ✅ SU-01: 正常系: 基本的な使用量計算が正しく行われる +- **ユニットテスト**: 各関数やコンポーネントの個別テスト + - テスト間の副作用を最小限に抑えるため、モックの使用は最低限にする + - Arrange-Act-Assertパターンを使用 + - 共通的なモックは `src/test/setup.ts` に配置 +- **e2eテスト**: Playwrightを使用した、シナリオベースのブラウザ自動化テスト + - Arrange-Act-Assertパターンを使用 + - Page Object Model パターンを使用して、頻出する操作を抽象化する + - `packages/extension/e2e/pages/` にページオブジェクトを配置 + - 詳細な設計書は `docs/test/e2e-test-spec.md` を参照、更新すること + - テストの実行前には、必ず `yarn build:e2e` を実行して最新のe2e向けビルドを生成すること diff --git a/packages/extension/e2e/data/test-settings.json b/packages/extension/e2e/data/test-settings.json index 61afc298..cdbd59ab 100644 --- a/packages/extension/e2e/data/test-settings.json +++ b/packages/extension/e2e/data/test-settings.json @@ -304,14 +304,20 @@ "openMode": "copy", "parentFolderId": "RootFolder", "revision": 0, - "title": "テキストコピー" + "title": "テキストコピー", + "iconUrl": "https://cdn0.iconfinder.com/data/icons/phosphor-light-vol-2/256/copy-light-1024.png" }, { "id": "cmd-test-linkpopup", "openMode": "linkPopup", "parentFolderId": "RootFolder", "revision": 0, - "title": "リンクポップアップ" + "title": "リンクポップアップ", + "iconUrl": "https://cdn3.iconfinder.com/data/icons/fluent-regular-24px-vol-5/24/ic_fluent_open_24_regular-1024.png", + "popupOption": { + "height": 700, + "width": 600 + } }, { "id": "cmd-test-deepl", diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index bd5293be..28cc4faa 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -10,7 +10,5 @@ import { APP_ID } from "../src/const" test("E2E-01: extension is injected into the test page", async ({ page }) => { const testPage = new TestPage(page) await testPage.open() - const locator = page.locator(`#${APP_ID}`) - await locator.waitFor({ state: "attached" }) - expect(locator).toBeVisible() + expect(page.locator(`#${APP_ID}`)).toBeVisible() }) diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index 1be1727f..bb7865a9 100644 --- a/packages/extension/e2e/pages/TestPage.ts +++ b/packages/extension/e2e/pages/TestPage.ts @@ -42,14 +42,14 @@ export class TestPage { async selectText(cssSelector = "h1, h2, h3"): Promise { await this.page.waitForFunction( ({ cssSelector }) => { - const heading = document.querySelector(cssSelector) - if (!heading) return false + const element = document.querySelector(cssSelector) + if (!element) return false // Scroll into view so getBoundingClientRect() returns valid coordinates. - heading.scrollIntoView() + element.scrollIntoView() // Find the first non-empty text node to build a selection range. - const textNode = Array.from(heading.childNodes).find( + const textNode = Array.from(element.childNodes).find( (n) => n.nodeType === Node.TEXT_NODE && (n.textContent?.trim().length ?? 0) > 0, @@ -74,7 +74,7 @@ export class TestPage { // Dispatch dblclick so SelectAnchor's onDouble handler fires and calls setAnchor(). // button: 0 (left) is required by isTargetEvent(); bubbles: true reaches document. const rect = range.getBoundingClientRect() - heading.dispatchEvent( + element.dispatchEvent( new MouseEvent("dblclick", { bubbles: true, cancelable: true, @@ -139,6 +139,91 @@ export class TestPage { ) } + /** + * Select text spanning from the first matching element of startSelector to + * the last matching element of endSelector using the Selection API, then + * dispatch the mouse/selection events the extension listens for. + * + * Uses the same polling approach as selectText() to handle the race condition + * where the extension's useEffect listeners are not yet registered when the + * page first loads. + * + * The selection covers the full text of both elements: it starts at the + * beginning of the first text node inside startSelector's element and ends + * at the end of the last text node inside endSelector's element, thereby + * encompassing the rectangular region that includes both elements. + */ + async selectRange(startSelector: string, endSelector: string): Promise { + await this.page.waitForFunction( + ({ startSelector, endSelector }) => { + const startElement = document.querySelector(startSelector) + const endElement = document.querySelector(endSelector) + if (!startElement || !endElement) return false + + startElement.scrollIntoView() + + // Walk the subtree and return the first non-empty Text node. + const findFirstTextNode = (el: Element): Text | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) + let node: Node | null + while ((node = walker.nextNode())) { + if ((node.textContent?.trim().length ?? 0) > 0) return node as Text + } + return null + } + + // Walk the subtree and return the last non-empty Text node. + const findLastTextNode = (el: Element): Text | null => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) + let last: Text | null = null + let node: Node | null + while ((node = walker.nextNode())) { + if ((node.textContent?.trim().length ?? 0) > 0) last = node as Text + } + return last + } + + const startNode = findFirstTextNode(startElement) + const endNode = findLastTextNode(endElement) + if (!startNode || !endNode) return false + + const range = document.createRange() + range.setStart(startNode, 0) + range.setEnd(endNode, endNode.textContent?.length ?? 0) + + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + document.dispatchEvent(new Event("selectionchange")) + + // Dispatch dblclick at the end element so SelectAnchor's onDouble() + // handler fires and calls setAnchor(), which is required for the popup + // to appear. Using mousedown + mouseup does NOT work here because + // SelectAnchor's mouseup handler only calls onDrag() when isDragging + // is already true — and isDragging is set by mousemove events that are + // only listened for while isMouseDown (React state) is true. Since + // React state updates are asynchronous, a synthetic mousemove + // dispatched right after mousedown is lost. dblclick bypasses this + // entirely via the onDouble() → setAnchor() path. + const endRect = endElement.getBoundingClientRect() + endElement.dispatchEvent( + new MouseEvent("dblclick", { + bubbles: true, + cancelable: true, + button: 0, + clientX: endRect.right, + clientY: endRect.bottom, + }), + ) + + return true + }, + { startSelector, endSelector }, + { polling: 50, timeout: 10_000 }, + ) + } + async getMenuBar(): Promise> { return this.page.locator(`[data-testid="${TEST_IDS.menuBar}"]`) } diff --git a/packages/extension/e2e/single-command.spec.ts b/packages/extension/e2e/single-command.spec.ts new file mode 100644 index 00000000..1f86bd00 --- /dev/null +++ b/packages/extension/e2e/single-command.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "./fixtures" +import { TestPage } from "./pages/TestPage" +import { OptionsPage } from "./pages/OptionsPage" + +test.describe("Single Function Commands", () => { + test.beforeEach(async ({ context, extensionId, getCommands }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.importSettings() + await optionsPage.close() + }) + + /** + * E2E-30: Verify that the Copy Text command copies the selected text to the clipboard. + */ + test("E2E-30: copy text command copies selected text to clipboard", async ({ + page, + context, + }) => { + // Arrange: open the test page, grant clipboard permissions, and select text + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText("h1") + const selectedText = await page.evaluate( + () => window.getSelection()?.toString() ?? "", + ) + expect(selectedText.length).toBeGreaterThan(1) + + // Act: click the "テキストコピー" menu item + const menubar = await testPage.getMenuBar() + await menubar.locator("[role='menuitem'][name='テキストコピー']").click() + await page.waitForTimeout(100) + + // Assert: clipboard content matches the selected text + const clipboardText = await page.evaluate(async () => { + try { + return await navigator.clipboard.readText() + } catch { + return null + } + }) + expect(clipboardText).toBe(selectedText) + }) + + /** + * E2E-31: Verify that the Link Popup command opens each link in the selected range in a popup window. + */ + test("E2E-31: link popup command opens each selected link in a popup window", async ({ + page, + context, + }) => { + // Arrange: open the test page and select a range spanning multiple links + const testPage = new TestPage(page) + await testPage.open() + const initialPageCount = context.pages().length + await testPage.selectRange( + "footer a[href$='terms']", + "footer a[href$='cookie']", + ) + + // Act: click the "リンクポップアップ" menu item and wait for a new page to open + const menubar = await testPage.getMenuBar() + await Promise.all([ + context.waitForEvent("page", { timeout: 5000 }), + menubar.locator("[role='menuitem'][name='リンクポップアップ']").click(), + ]) + + // Assert: at least one new popup window was opened + const newPageCount = context.pages().length + expect(newPageCount).toBeGreaterThan(initialPageCount) + }) +}) From fdb9051981d5c0c1daf02ac4976a1cf9a4520627 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 24 Mar 2026 10:23:33 +0900 Subject: [PATCH 07/38] Update: prettier settings. --- package.json | 11 +++++++++-- packages/extension/package.json | 8 -------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 167779fe..5375d998 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "tsc": "yarn workspaces run tsc -b", "clean": "yarn workspaces run clean && rm -rf node_modules", "add-command": "yarn workspace @selection-command/hub add-command", - "check-ids": "yarn workspace @selection-command/extension check-ids" + "check-ids": "yarn workspace @selection-command/extension check-ids", + "pretty-quick": "pretty-quick" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -47,5 +48,11 @@ "node": ">=18.0.0", "yarn": ">=1.22.0" }, - "dependencies": {} + "dependencies": {}, + "prettier": { + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all" + } } diff --git a/packages/extension/package.json b/packages/extension/package.json index bc82c435..b799e524 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -14,8 +14,6 @@ "test:ui": "vitest --ui", "test:run": "vitest run", "test:coverage": "vitest --coverage", - "pretty-quick": "pretty-quick", - "precommit": "pretty-quick --staged", "check-ids": "vite-node --config scripts/vite.config.ts scripts/check-command-ids.ts", "zip": "npm-build-zip --source=dist --destination=build", "test:e2e": "playwright test" @@ -91,11 +89,5 @@ "development": [ "last 3 chrome version" ] - }, - "prettier": { - "semi": false, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "all" } } From 5df85258e2c3d858e48ac63ef8dcffa4981e80ed Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 25 Mar 2026 09:45:44 +0900 Subject: [PATCH 08/38] Update: Dont use `expect` in the page object. --- packages/extension/e2e/extension.spec.ts | 3 +-- packages/extension/e2e/pages/TestPage.ts | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index 28cc4faa..eb3a8e7a 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -1,6 +1,5 @@ import { test, expect } from "./fixtures" import { TestPage } from "./pages/TestPage" -// import { OptionsPage } from "./pages/OptionsPage" import { APP_ID } from "../src/const" /** @@ -10,5 +9,5 @@ import { APP_ID } from "../src/const" test("E2E-01: extension is injected into the test page", async ({ page }) => { const testPage = new TestPage(page) await testPage.open() - expect(page.locator(`#${APP_ID}`)).toBeVisible() + await expect(page.locator(`#${APP_ID}`)).toBeAttached() }) diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index bb7865a9..2f17f016 100644 --- a/packages/extension/e2e/pages/TestPage.ts +++ b/packages/extension/e2e/pages/TestPage.ts @@ -1,22 +1,20 @@ -import { expect, type Page, type Locator } from "@playwright/test" +import { type Page, type Locator } from "@playwright/test" import { TEST_IDS } from "@/testIds" const TEST_URL = "https://ujiro99.github.io/selection-command/en/test" -const APP_ID = "selection-command" /** * Page Object for the extension's test page. * 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. */ async open(): Promise { await this.page.goto(TEST_URL) - await expect(this.page.locator(`#${APP_ID}`)).toBeAttached() } /** From 4c677cc971b9e66ecb73960c37d9b55c9034329f Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 25 Mar 2026 11:32:38 +0900 Subject: [PATCH 09/38] Fix: e2e tests. --- .serena/project.yml | 16 ++++++++- packages/extension/e2e/pages/OptionsPage.ts | 7 +++- packages/extension/e2e/pages/TestPage.ts | 39 +++++++++++++++++---- packages/extension/e2e/startup.spec.ts | 4 +-- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 98f1bf56..b40c0729 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -101,7 +101,7 @@ encoding: utf-8 # yaml zig # languages: - - typescript +- typescript # time budget (seconds) per tool call for the retrieval of additional symbol information # such as docstrings or parameter information. @@ -124,3 +124,17 @@ line_ending: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/packages/extension/e2e/pages/OptionsPage.ts b/packages/extension/e2e/pages/OptionsPage.ts index 7ff45148..f66514e7 100644 --- a/packages/extension/e2e/pages/OptionsPage.ts +++ b/packages/extension/e2e/pages/OptionsPage.ts @@ -89,6 +89,11 @@ export class OptionsPage { await okButton.click() await reloadPromise + // Load the settings file to know the expected command count + const rawJson = fs.readFileSync(settingsPath, "utf-8") + const settingsJson = JSON.parse(rawJson) + const expectedCommandCount: number = settingsJson.commands?.length ?? 0 + // Wait for the settings to be loaded with commands await expect .poll(async () => await this.getCommands(), { @@ -96,7 +101,7 @@ export class OptionsPage { timeout: 5000, intervals: [40], }) - .not.toBeUndefined() + .toHaveLength(expectedCommandCount) } /** diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index 2f17f016..2c4dfe47 100644 --- a/packages/extension/e2e/pages/TestPage.ts +++ b/packages/extension/e2e/pages/TestPage.ts @@ -1,5 +1,6 @@ import { type Page, type Locator } from "@playwright/test" import { TEST_IDS } from "@/testIds" +import { APP_ID } from "../../src/const" const TEST_URL = "https://ujiro99.github.io/selection-command/en/test" @@ -15,6 +16,9 @@ export class TestPage { */ async open(): Promise { await this.page.goto(TEST_URL) + await this.page + .locator(`#${APP_ID}`) + .waitFor({ state: "attached", timeout: 10_000 }) } /** @@ -26,20 +30,23 @@ export class TestPage { * there is a race condition: #selection-command appears in the DOM before * useEffect has run, so events dispatched immediately after open() are lost. * - * waitForFunction polls every 500ms (> the 250ms popup delay). On each poll it: + * waitForFunction polls every 50ms. On each poll it: * 1. Re-creates the text selection via the Selection API. * 2. Dispatches selectionchange + dblclick so the extension can process them. - * 3. Returns true only when [data-state="open"] is present in document.body, - * which is where Radix UI portals the popup outside the shadow DOM. + * 3. Returns true only when [data-testid="menu-bar"] is present in the + * extension's shadow DOM (id="selection-command", mode="open" in E2E). * * If the listeners are not registered yet the events are lost and the popup * does not appear; the function returns false and polling retries. Once the * listeners are registered the popup appears within 250ms and the next poll * detects it. */ - async selectText(cssSelector = "h1, h2, h3"): Promise { + async selectText( + cssSelector = "h1, h2, h3", + waitForMenu = true, + ): Promise { await this.page.waitForFunction( - ({ cssSelector }) => { + ({ cssSelector, appId, menuBarTestId, waitForMenu }) => { const element = document.querySelector(cssSelector) if (!element) return false @@ -82,9 +89,27 @@ export class TestPage { }), ) - return true + // If waitForMenu is false, return true immediately after dispatching + // events. This is used when the startup method requires an additional + // action (e.g. keyboard shortcut, left-click hold) to show the menu. + if (!waitForMenu) return true + + // Return true only when the popup's menu bar is visible in the shadow DOM. + // The extension mounts React inside a shadow root (id=APP_ID, mode="open" + // during E2E), so we must pierce the shadow root manually — waitForFunction + // runs in the browser JS context, not via Playwright's auto-pierce mechanism. + // Returning false keeps the poll running so that events are re-dispatched on + // the next tick if the useEffect listeners were not yet registered when the + // first dispatch ran. + const shadowRoot = document.getElementById(appId)?.shadowRoot + return !!shadowRoot?.querySelector(`[data-testid="${menuBarTestId}"]`) + }, + { + cssSelector, + appId: APP_ID, + menuBarTestId: TEST_IDS.menuBar, + waitForMenu, }, - { cssSelector }, { polling: 50, timeout: 10_000 }, ) } diff --git a/packages/extension/e2e/startup.spec.ts b/packages/extension/e2e/startup.spec.ts index 2954025f..a33d4cc0 100644 --- a/packages/extension/e2e/startup.spec.ts +++ b/packages/extension/e2e/startup.spec.ts @@ -30,7 +30,7 @@ test("E2E-11: popup menu appears on text selection and press a ShiftKey", async keyboardParam: KEYBOARD.SHIFT, }, }) - await testPage.selectText() + await testPage.selectText("h1, h2, h3", false) await page.keyboard.press(KEYBOARD.SHIFT) // Act: Set the startup method to "shortcut" and dispatch the keyboard shortcut. @@ -57,7 +57,7 @@ test("E2E-12: popup menu appears on left-click hold (150ms)", async ({ }, }) - await testPage.selectText("h1, h2") + await testPage.selectText("h1, h2", false) // Long-press via synthetic MouseEvent to preserve text selection in headless mode. // locator.click({ delay }) uses CDP which fires selectionchange on mousedown and From b354436ab993171091d068fa2472350351ebbb7a Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 25 Mar 2026 13:08:10 +0900 Subject: [PATCH 10/38] Update: Add e2e tests for UserStyle. --- docs/test/e2e-test-spec.md | 57 +++- packages/extension/e2e/pages/OptionsPage.ts | 70 +++++ packages/extension/e2e/user-style.spec.ts | 261 ++++++++++++++++++ .../src/components/option/EditButton.tsx | 3 + .../src/components/option/RemoveButton.tsx | 6 + .../src/components/option/RemoveDialog.tsx | 7 +- .../option/editor/UserStyleList.tsx | 9 + packages/extension/src/testIds.ts | 6 + 8 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 packages/extension/e2e/user-style.spec.ts diff --git a/docs/test/e2e-test-spec.md b/docs/test/e2e-test-spec.md index ca176e46..2e1041a1 100644 --- a/docs/test/e2e-test-spec.md +++ b/docs/test/e2e-test-spec.md @@ -46,10 +46,12 @@ | E2E-64 | リンクプレビュー | Open Mode | Window | 未実装 | | E2E-65 | リンクプレビュー | Open Mode | SidePanel | 未実装 | | E2E-66 | リンクプレビュー | ドラッグ距離 | 50px | 未実装 | -| E2E-70 | ユーザースタイル | 余白サイズ | 1 | 未実装 | -| E2E-71 | ユーザースタイル | 余白サイズ | 1.5 | 未実装 | -| E2E-72 | ユーザースタイル | 背景色 | 設定 | 未実装 | -| E2E-73 | ユーザースタイル | 背景色 | 削除 | 未実装 | +| E2E-70 | ユーザースタイル | 余白サイズ | 1 | 実装済 | +| E2E-71 | ユーザースタイル | 余白サイズ | 1.5 | 実装済 | +| E2E-72 | ユーザースタイル | 背景色 | 設定 | 実装済 | +| E2E-73 | ユーザースタイル | 背景色 | 削除 | 実装済 | +| E2E-74 | ユーザースタイル | 文字色 | 設定 | 実装済 | +| E2E-75 | ユーザースタイル | 文字色 | 削除 | 実装済 | | E2E-80 | 設定画面 | インポート | ポップアップ | 実装済 | | E2E-81 | 設定画面 | インポート | リンクプレビュー | 実装済 | | E2E-82 | 設定画面 | インポート | コマンド100件以上 | 実装済 | @@ -772,6 +774,53 @@ --- +#### E2E-74: 文字色の設定 + +**事前条件** + +- 設定ページが開ける状態 +- ユーザースタイルに文字色(`font-color`)が未設定の状態 + +**手順** + +1. 設定ページを開く +2. ユーザースタイルの追加ボタン(`data-testid="user-style-add-button"`)をクリックする +3. ダイアログで変数名に「文字色(`font-color`)」を選択する +4. 値に任意の色(例: `#ff0000`)を入力する +5. 保存ボタン(`data-testid="user-style-save-button"`)をクリックする +6. テストページを開く +7. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ユーザースタイルのリスト(`data-testid="user-style-item"`)に文字色の設定が追加される +- ポップアップメニューのテキスト色が設定した色(`rgb(255, 0, 0)`)に変更される + +--- + +#### E2E-75: 文字色の削除 + +**事前条件** + +- 文字色(`font-color`)が設定済みの状態 +- 他にユーザースタイル設定(例: `padding-scale`)も存在する状態 + +**手順** + +1. 設定ページを開く +2. ユーザースタイルリストで文字色のアイテムの削除ボタン(`data-testid="user-style-remove-button"`)をクリックする +3. 確認ダイアログで OK ボタン(`data-testid="user-style-remove-ok-button"`)をクリックする +4. テストページを開く +5. テキストを選択してポップアップメニューを表示する + +**期待動作** + +- ユーザースタイルのリストから文字色の設定が削除される +- ポップアップメニューの文字色の CSS 変数(`--font-color`)が未設定になる +- 他のユーザースタイル設定(`padding-scale` 等)はリストに残り影響を受けない + +--- + ### 設定画面 --- diff --git a/packages/extension/e2e/pages/OptionsPage.ts b/packages/extension/e2e/pages/OptionsPage.ts index f66514e7..d2cc7fe1 100644 --- a/packages/extension/e2e/pages/OptionsPage.ts +++ b/packages/extension/e2e/pages/OptionsPage.ts @@ -123,6 +123,76 @@ export class OptionsPage { return fs.readFileSync(filePath, "utf-8") } + /** + * Add a user style via the UI (add button → dialog → save). + * Waits for the auto-save debounce to flush to storage. + */ + async addUserStyle(name: string, value: string): Promise { + if (!this.page) throw new Error("Options page not open") + const page = this.page + + const addButton = page.locator( + `[data-testid="${TEST_IDS.userStyleAddButton}"]`, + ) + await addButton.scrollIntoViewIfNeeded() + await addButton.click() + + // Wait for the dialog content to appear + const dialog = page.locator("#UserStyleDialog") + await dialog.waitFor({ state: "visible", timeout: 3000 }) + + // Open the variable name dropdown and select the target option + const selectTrigger = dialog.locator('[role="combobox"]') + await selectTrigger.click() + await page.locator(`[data-value="${name}"]`).click() + + // Fill in the color/value field (after variable change resets the default) + const valueInput = dialog.locator("input") + await valueInput.evaluate((el: HTMLInputElement, v: string) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set + setter?.call(el, v) + el.dispatchEvent(new Event("input", { bubbles: true })) + el.dispatchEvent(new Event("change", { bubbles: true })) + }, value) + + // Click the save button in the dialog + await page + .locator(`[data-testid="${TEST_IDS.userStyleSaveButton}"]`) + .click() + + // Wait for the 500ms auto-save debounce to flush to storage + await page.waitForTimeout(700) + } + + /** + * Remove a user style by variable name via the UI (remove button → confirm dialog). + * Waits for the auto-save debounce to flush to storage. + */ + async removeUserStyle(name: string): Promise { + if (!this.page) throw new Error("Options page not open") + const page = this.page + + const item = page.locator( + `[data-testid="${TEST_IDS.userStyleItem}"][data-name="${name}"]`, + ) + await item.scrollIntoViewIfNeeded() + await item + .locator(`[data-testid="${TEST_IDS.userStyleRemoveButton}"]`) + .click() + + const okButton = page.locator( + `[data-testid="${TEST_IDS.userStyleRemoveOkButton}"]`, + ) + await okButton.waitFor({ state: "visible", timeout: 3000 }) + await okButton.click() + + // Wait for the 500ms auto-save debounce to flush to storage + await page.waitForTimeout(700) + } + /** * Reset settings to defaults via the Reset button and confirm the dialog. */ diff --git a/packages/extension/e2e/user-style.spec.ts b/packages/extension/e2e/user-style.spec.ts new file mode 100644 index 00000000..50d024fc --- /dev/null +++ b/packages/extension/e2e/user-style.spec.ts @@ -0,0 +1,261 @@ +import { test, expect } from "./fixtures" +import { TestPage } from "./pages/TestPage" +import { OptionsPage } from "./pages/OptionsPage" + +test.describe("User Styles", () => { + test.beforeEach(async ({ context, extensionId, getCommands }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.importSettings() + await optionsPage.close() + }) + + /** + * E2E-70: Verify that setting padding-scale to 1 makes the popup menu narrower + * than the default (1.5). + */ + test("E2E-70: padding-scale 1 makes menu items smaller than default", async ({ + getUserSettings, + setUserSettings, + page, + }) => { + // First measure the default (1.5) button height + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + const defaultButton = menubar.locator("button").first() + const defaultBox = await defaultButton.boundingBox() + expect(defaultBox).toBeTruthy() + const defaultHeight = defaultBox!.height + + // Close menu by clicking on the selected element to deselect text + await page.locator("h1, h2, h3").first().click() + await page.waitForTimeout(100) + + // Change padding-scale to 1 + const currentSettings = await getUserSettings() + const updatedStyles = currentSettings.userStyles.map((s) => + s.name === "padding-scale" ? { ...s, value: "1" } : s, + ) + await setUserSettings({ userStyles: updatedStyles }) + + // Re-open the menu + await testPage.selectText() + const menubar2 = await testPage.getMenuBar() + await expect(menubar2).toBeVisible() + + const scaledButton = menubar2.locator("button").first() + const scaledBox = await scaledButton.boundingBox() + expect(scaledBox).toBeTruthy() + + // With padding-scale=1 the button height should be smaller + expect(scaledBox!.height).toBeLessThan(defaultHeight) + }) + + /** + * E2E-71: Verify that restoring padding-scale to 1.5 brings back the default size. + */ + test("E2E-71: padding-scale 1.5 restores default menu item size", async ({ + getUserSettings, + setUserSettings, + page, + }) => { + // Set padding-scale to 1 first + const currentSettings = await getUserSettings() + const smallStyles = currentSettings.userStyles.map((s) => + s.name === "padding-scale" ? { ...s, value: "1" } : s, + ) + await setUserSettings({ userStyles: smallStyles }) + + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + const smallBox = await menubar.locator("button").first().boundingBox() + expect(smallBox).toBeTruthy() + + // Close menu by clicking on the selected element to deselect text + await page.locator("h1, h2, h3").first().click() + await page.waitForTimeout(100) + + // Restore padding-scale to 1.5 + const settings2 = await getUserSettings() + const defaultStyles = settings2.userStyles.map((s) => + s.name === "padding-scale" ? { ...s, value: "1.5" } : s, + ) + await setUserSettings({ userStyles: defaultStyles }) + + // Re-open the menu + await testPage.selectText() + const menubar2 = await testPage.getMenuBar() + await expect(menubar2).toBeVisible() + + const defaultBox = await menubar2.locator("button").first().boundingBox() + expect(defaultBox).toBeTruthy() + + // Default (1.5) button should be larger than scale=1 + expect(defaultBox!.height).toBeGreaterThan(smallBox!.height) + }) + + /** + * E2E-72: Verify that setting a background color changes the popup menu background. + */ + test("E2E-72: setting background color changes the popup menu background", async ({ + getUserSettings, + setUserSettings, + page, + }) => { + const currentSettings = await getUserSettings() + // Add or update background-color style variable + const hasBackgroundColor = currentSettings.userStyles.some( + (s) => s.name === "background-color", + ) + const updatedStyles = hasBackgroundColor + ? currentSettings.userStyles.map((s) => + s.name === "background-color" ? { ...s, value: "#ff0000" } : s, + ) + : [ + ...currentSettings.userStyles, + { name: "background-color", value: "#ff0000" } as any, + ] + + await setUserSettings({ userStyles: updatedStyles }) + + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + // Read the CSS custom property from the menu element via the locator + const bgColor = await menubar.evaluate( + (el) => + getComputedStyle(el).getPropertyValue("--background-color").trim() || + getComputedStyle(el).backgroundColor, + ) + + // The background-color style variable should be set to red + expect(bgColor).toContain("rgb(255, 0, 0)") + }) + + /** + * E2E-73: Verify that deleting the background color resets the popup menu background to white. + */ + test("E2E-73: deleting background color resets popup menu to default background", async ({ + getUserSettings, + setUserSettings, + page, + }) => { + // First, set a background color + const currentSettings = await getUserSettings() + const withBg = [ + ...currentSettings.userStyles.filter( + (s) => s.name !== "background-color", + ), + { name: "background-color", value: "#ff0000" } as any, + ] + await setUserSettings({ userStyles: withBg }) + + // Then remove it + const settings2 = await getUserSettings() + const withoutBg = settings2.userStyles.filter( + (s) => s.name !== "background-color", + ) + await setUserSettings({ userStyles: withoutBg }) + + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + // The background-color custom property should not be set to red + const bgColor = await menubar.evaluate((el) => + getComputedStyle(el).getPropertyValue("--background-color").trim(), + ) + + expect(bgColor).not.toBe("#ff0000") + expect(bgColor).not.toContain("rgb(255, 0, 0)") + }) + + /** + * E2E-74: Verify that adding font-color via the settings UI applies to the popup menu. + */ + test("E2E-74: adding font-color via UI applies to popup menu", async ({ + context, + extensionId, + getCommands, + page, + }) => { + // Add font-color via the options page UI + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.addUserStyle("font-color", "#ff0000") + await optionsPage.close() + + // Verify the font color is applied in the popup menu + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + const fontColor = await menubar.evaluate((el) => getComputedStyle(el).color) + expect(fontColor).toContain("rgb(255, 0, 0)") + }) + + /** + * E2E-75: Verify that removing font-color via the settings UI resets the popup menu + * font color, and that other user styles are unaffected. + */ + test("E2E-75: removing font-color via UI resets popup menu font color", async ({ + context, + extensionId, + getCommands, + getUserSettings, + setUserSettings, + page, + }) => { + // Pre-condition: add font-color on top of the existing styles + const currentSettings = await getUserSettings() + await setUserSettings({ + userStyles: [ + ...currentSettings.userStyles, + { name: "font-color", value: "#ff0000" } as any, + ], + }) + + // Remove font-color via the options page UI + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.removeUserStyle("font-color") + await optionsPage.close() + + // Verify font color is back to default in the popup menu + const testPage = new TestPage(page) + await testPage.open() + await testPage.selectText() + const menubar = await testPage.getMenuBar() + await expect(menubar).toBeVisible() + + const fontColor = await menubar.evaluate((el) => getComputedStyle(el).color) + expect(fontColor).not.toContain("rgb(255, 0, 0)") + + // Verify other user styles (e.g. padding-scale) are unaffected + const updatedSettings = await getUserSettings() + expect( + updatedSettings.userStyles.some((s) => s.name === "padding-scale"), + ).toBe(true) + expect( + updatedSettings.userStyles.some((s) => s.name === "image-scale"), + ).toBe(true) + expect( + updatedSettings.userStyles.some((s) => s.name === "font-scale"), + ).toBe(true) + }) +}) diff --git a/packages/extension/src/components/option/EditButton.tsx b/packages/extension/src/components/option/EditButton.tsx index 652b5582..46e8a6e5 100644 --- a/packages/extension/src/components/option/EditButton.tsx +++ b/packages/extension/src/components/option/EditButton.tsx @@ -9,6 +9,7 @@ type EditButtonProps = { size?: number className?: string classNameIcon?: string + "data-testid"?: string } export const EditButton = ({ @@ -16,6 +17,7 @@ export const EditButton = ({ size = 16, className, classNameIcon, + "data-testid": dataTestId, }: EditButtonProps) => { const buttonRef = useRef(null) const handleClick = (e: React.SyntheticEvent) => { @@ -31,6 +33,7 @@ export const EditButton = ({ className, )} onClick={handleClick} + data-testid={dataTestId} > { const buttonRef = useRef(null) const [open, setOpen] = useState(false) @@ -36,6 +40,7 @@ export const RemoveButton = ({ )} onClick={() => setOpen(true)} ref={buttonRef} + data-testid={dataTestId} > <> {(iconUrl != null || iconSvg != null) && ( diff --git a/packages/extension/src/components/option/RemoveDialog.tsx b/packages/extension/src/components/option/RemoveDialog.tsx index 73f7ded5..ca68b89f 100644 --- a/packages/extension/src/components/option/RemoveDialog.tsx +++ b/packages/extension/src/components/option/RemoveDialog.tsx @@ -21,9 +21,13 @@ type RemoveDialogProps = { onRemove: () => void children: React.ReactNode portal?: boolean + "data-testid-ok"?: string } -export const RemoveDialog = (props: RemoveDialogProps) => { +export const RemoveDialog = ({ + "data-testid-ok": dataTestIdOk, + ...props +}: RemoveDialogProps) => { const closeRef = useRef(null) const handleOpenAutoFocus = (e: Event) => { closeRef.current?.focus() @@ -53,6 +57,7 @@ export const RemoveDialog = (props: RemoveDialogProps) => { size="lg" onClick={() => props.onRemove()} ref={closeRef} + data-testid={dataTestIdOk} > {t("Option_remove_ok")} diff --git a/packages/extension/src/components/option/editor/UserStyleList.tsx b/packages/extension/src/components/option/editor/UserStyleList.tsx index 7da74293..2fd6d245 100644 --- a/packages/extension/src/components/option/editor/UserStyleList.tsx +++ b/packages/extension/src/components/option/editor/UserStyleList.tsx @@ -40,6 +40,7 @@ import { STYLE_VARIABLE } from "@/const" import { cn, hyphen2Underscore } from "@/lib/utils" import { Attributes } from "@/services/option/userStyles" import { t as _t } from "@/services/i18n" +import { TEST_IDS } from "@/testIds" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) type UnitMap = Record @@ -132,6 +133,7 @@ export const UserStyleList = ({ control }: UserStyleListProps) => { }} ref={addButtonRef} disabled={selectedAll} + data-testid={TEST_IDS.userStyleAddButton} > {t("userStyles") @@ -165,6 +167,8 @@ export const UserStyleList = ({ control }: UserStyleListProps) => { "flex items-center gap-2 px-2 py-1", filteredIndex !== 0 ? "border-t" : "", )} + data-testid={TEST_IDS.userStyleItem} + data-name={field.name} >

{fieldLabel} @@ -178,10 +182,13 @@ export const UserStyleList = ({ control }: UserStyleListProps) => { editorRef.current = field setDialogOpen(true) }} + data-testid={TEST_IDS.userStyleEditButton} /> array.remove(originalIndex)} + data-testid={TEST_IDS.userStyleRemoveButton} + data-testid-ok={TEST_IDS.userStyleRemoveOkButton} /> @@ -284,6 +291,7 @@ export const UserStyleDialog = ({ value={opt} key={opt} className="hover:bg-gray-100" + data-value={opt} > {t( `userStyles_option_${hyphen2Underscore(opt)}`, @@ -332,6 +340,7 @@ export const UserStyleDialog = ({ onSubmit(data) onOpenChange(false) })} + data-testid={TEST_IDS.userStyleSaveButton} > {isUpdate ? t("labelUpdate") : t("labelSave")} diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts index a5aa3e9d..b7cb54b8 100644 --- a/packages/extension/src/testIds.ts +++ b/packages/extension/src/testIds.ts @@ -6,4 +6,10 @@ export const TEST_IDS = { optionDialogCancel: "option-dialog-cancel", exportButton: "export-button", resetButton: "reset-button", + userStyleAddButton: "user-style-add-button", + userStyleSaveButton: "user-style-save-button", + userStyleItem: "user-style-item", + userStyleEditButton: "user-style-edit-button", + userStyleRemoveButton: "user-style-remove-button", + userStyleRemoveOkButton: "user-style-remove-ok-button", } From 14783b77eed61d52e25bd70f77c6b168a84385ec Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 25 Mar 2026 13:15:42 +0900 Subject: [PATCH 11/38] Update: Add exclude folders. --- vitest.config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index a18e5899..13458271 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vitest/config" export default defineConfig({ test: { @@ -16,7 +16,9 @@ export default defineConfig({ "**/build/**", "**/.next/**", "**/coverage/**", + "**/e2e/**", + "**/scripts/**", ], }, }, -}); +}) From e07e7bbc78114c962b60d33df180dd21674e3f50 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Fri, 27 Mar 2026 11:38:51 +0900 Subject: [PATCH 12/38] Update: Add e2e tests for LinkPreview. --- packages/extension/e2e/fixtures.ts | 24 ++ packages/extension/e2e/link-preview.spec.ts | 305 ++++++++++++++++++ packages/extension/e2e/pages/OptionsPage.ts | 28 ++ packages/extension/e2e/utils/logConsole.ts | 34 ++ packages/extension/src/action/aiPrompt.ts | 4 +- packages/extension/src/action/helper.ts | 9 +- packages/extension/src/action/linkPreview.ts | 24 +- packages/extension/src/action/pageAction.ts | 4 +- packages/extension/src/action/popup.ts | 4 +- .../extension/src/action/selectedLinkPopup.ts | 11 +- packages/extension/src/action/window.ts | 4 +- packages/extension/src/background_script.ts | 5 - packages/extension/src/components/App.tsx | 3 + .../components/option/field/SelectField.tsx | 7 +- .../extension/src/services/backgroundData.ts | 2 - packages/extension/src/services/chrome.ts | 33 +- .../src/services/pageAction/background.ts | 6 +- packages/extension/src/services/screen.ts | 106 +++--- .../src/services/windowStackManager.test.ts | 1 - packages/extension/src/testIds.ts | 2 + 20 files changed, 504 insertions(+), 112 deletions(-) create mode 100644 packages/extension/e2e/link-preview.spec.ts create mode 100644 packages/extension/e2e/utils/logConsole.ts diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index a9f5b13d..1517a0d9 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -6,6 +6,7 @@ import { } from "@playwright/test" import path from "path" import { fileURLToPath } from "url" +import { attachSWConsole } from "./utils/logConsole" import type { UserSettings, Command } from "@/types" import type { CommandMetadata } from "@/types/command" @@ -26,6 +27,7 @@ type Fixtures = { getUserSettings: () => Promise setUserSettings: (newSettings: Partial) => Promise getCommands: () => Promise + isAllWindowsNormal: () => Promise } /** @@ -48,6 +50,14 @@ export const test = base.extend({ `--load-extension=${pathToExtension}`, ], }) + + // Wait for the service worker to be ready before proceeding, so tests can interact with it immediately. + let [sw] = context.serviceWorkers() + if (!sw) { + sw = await context.waitForEvent("serviceworker") + } + attachSWConsole(sw) + await use(context) await context.close() }, @@ -161,6 +171,20 @@ export const test = base.extend({ return result }) }, + + isAllWindowsNormal: async ({ context }, use) => { + let [serviceWorker] = context.serviceWorkers() + if (!serviceWorker) { + serviceWorker = await context.waitForEvent("serviceworker") + } + await use(async () => { + const result = await serviceWorker.evaluate(async () => { + const windows = await chrome.windows.getAll({ populate: false }) + return !windows.some((win) => win.type !== "normal") + }) + return result + }) + }, }) export const expect = test.expect diff --git a/packages/extension/e2e/link-preview.spec.ts b/packages/extension/e2e/link-preview.spec.ts new file mode 100644 index 00000000..237dc102 --- /dev/null +++ b/packages/extension/e2e/link-preview.spec.ts @@ -0,0 +1,305 @@ +import { test, expect } from "./fixtures" +import { TestPage } from "./pages/TestPage" +import { OptionsPage } from "./pages/OptionsPage" +import { attachConsole } from "./utils/logConsole" +import { DRAG_OPEN_MODE, LINK_COMMAND_STARTUP_METHOD } from "../src/const" + +test.describe("Link Preview", () => { + test.beforeEach(async ({ context, extensionId, getCommands }) => { + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.importSettings() + await optionsPage.close() + }) + + /** + * E2E-60: Verify that Shift+click on a link opens a link preview popup. + * test-settings have linkCommand.enabled="Enable", method="keyboard" (Shift). + */ + test("E2E-60: link preview opens on Shift + click", async ({ + context, + page, + }) => { + attachConsole(page) + + const testPage = new TestPage(page) + await testPage.open() + + const link = page.locator("a[href]").first() + await expect(link).toBeVisible() + const href = await link.getAttribute("href") + expect(href).toBeTruthy() + + // React 側が ready になってからネイティブ Shift+click + const [previewPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 5000 }), + await link.click({ + modifiers: ["Shift"], + }), + ]) + + await previewPage.waitForLoadState("domcontentloaded") + + expect(previewPage.url()).not.toBe("") + expect(previewPage.url()).not.toContain("about:blank") + }) + + /** + * E2E-61: Drag-based link preview. + * test-settings have linkCommand.enabled="Enable", openMode="previewPopup". + * Changes startupMethod to "drag" and simulates a drag >threshold pixels. + */ + test("E2E-61: link preview opens on drag", async ({ + context, + getUserSettings, + setUserSettings, + page, + }) => { + attachConsole(page) + + const currentSettings = await getUserSettings() + const threshold = + currentSettings.linkCommand?.startupMethod?.threshold ?? 150 + + await setUserSettings({ + linkCommand: { + ...currentSettings.linkCommand, + startupMethod: { + ...currentSettings.linkCommand?.startupMethod, + method: LINK_COMMAND_STARTUP_METHOD.DRAG, + }, + }, + }) + + const testPage = new TestPage(page) + await testPage.open() + + const link = page.locator("a[href]").first() + await expect(link).toBeVisible() + + const box = await link.boundingBox() + expect(box).toBeTruthy() + const x = box!.x + box!.width / 2 + const y = box!.y + box!.height / 2 + + const [previewPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 5000 }), + (async () => { + await page.mouse.move(x, y) + await page.mouse.down() + // Drag further than threshold to activate link preview + await page.mouse.move(x, y + threshold + 50, { steps: 10 }) + await page.mouse.up() + })(), + ]) + + await previewPage.waitForLoadState("domcontentloaded") + expect(previewPage.url()).not.toBe("") + expect(previewPage.url()).not.toContain("about:blank") + }) + + /** + * E2E-62: Long-press link preview. + * Changes startupMethod to "leftClickHold" and holds mouse button for + * leftClickHoldParam ms to trigger link preview. + */ + test("E2E-62: link preview opens on left-click long press", async ({ + context, + getUserSettings, + setUserSettings, + page, + }) => { + attachConsole(page) + + const currentSettings = await getUserSettings() + const holdDuration = + currentSettings.linkCommand?.startupMethod?.leftClickHoldParam ?? 200 + + await setUserSettings({ + linkCommand: { + ...currentSettings.linkCommand, + startupMethod: { + ...currentSettings.linkCommand?.startupMethod, + method: LINK_COMMAND_STARTUP_METHOD.LEFT_CLICK_HOLD, + }, + }, + }) + + const testPage = new TestPage(page) + await testPage.open() + + const link = page.locator("a[href]").first() + await expect(link).toBeVisible() + + const box = await link.boundingBox() + expect(box).toBeTruthy() + const x = box!.x + box!.width / 2 + const y = box!.y + box!.height / 2 + + const [previewPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 8000 }), + (async () => { + await page.mouse.move(x, y) + await page.mouse.down() + // Hold longer than leftClickHoldParam to trigger link preview + await page.waitForTimeout(holdDuration + 100) + await page.mouse.up() + })(), + ]) + + await previewPage.waitForLoadState("domcontentloaded") + expect(previewPage.url()).not.toBe("") + expect(previewPage.url()).not.toContain("about:blank") + }) + + /** + * E2E-63: Image download link preview. + * Injects an image download link into the test page and verifies that + * Shift+click opens a link preview popup for the image URL. + */ + test("E2E-63: link preview works for image download links", async ({ + context, + page, + }) => { + attachConsole(page) + + const testPage = new TestPage(page) + await testPage.open() + + // Inject an image download link at a fixed position so it is always visible + await page.evaluate(() => { + const a = document.createElement("a") + a.href = + "https://ujiro99.github.io/selection-command/chrome_web_store.png" + a.download = "chrome_web_store.png" + a.textContent = "Download Image" + a.style.cssText = + "display:block; position:fixed; top:10px; left:10px; z-index:9999;" + document.body.prepend(a) + }) + + const link = page.locator("a[download='chrome_web_store.png']") + await expect(link).toBeVisible() + + const [previewPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 5000 }), + await link.click({ modifiers: ["Shift"] }), + ]) + + await previewPage.waitForLoadState("domcontentloaded") + expect(previewPage.url()).not.toBe("") + expect(previewPage.url()).not.toContain("about:blank") + expect(previewPage.url()).toContain("chrome_web_store.png") + }) + + /** + * E2E-64: Verify that a link preview with OpenMode Window opens a new window. + */ + test("E2E-64: link preview opens in a window when OpenMode is Window", async ({ + context, + extensionId, + getCommands, + isAllWindowsNormal, + page, + }) => { + attachConsole(page) + + const optionsPage = new OptionsPage(context, extensionId, getCommands) + await optionsPage.open() + await optionsPage.setLinkCommandOpenMode(DRAG_OPEN_MODE.PREVIEW_WINDOW) + await optionsPage.close() + + const testPage = new TestPage(page) + await testPage.open() + + const link = page.locator("a[href]").first() + await expect(link).toBeVisible() + + const [previewPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 5000 }), + await link.click({ + modifiers: ["Shift"], + delay: 50, + }), + ]) + + await previewPage.waitForLoadState("domcontentloaded") + expect(previewPage.url()).not.toBe("") + expect(await isAllWindowsNormal()).toBeTruthy() + }) + + /** + * E2E-65: Side panel link preview. + * Skipped: side panel verification is not reliably possible in headless Chrome. + */ + test.skip("E2E-65: link preview opens in side panel", async () => { + // Side panel verification not reliably possible in headless Chrome + }) + + /** + * E2E-66: Drag distance threshold for link preview. + * Sets threshold=50 and verifies: + * - drag < threshold → no preview opens + * - drag > threshold → preview opens + */ + test("E2E-66: link preview activates only when drag distance >= 50px", async ({ + context, + getUserSettings, + setUserSettings, + page, + }) => { + attachConsole(page) + + const threshold = 50 + + const currentSettings = await getUserSettings() + await setUserSettings({ + linkCommand: { + ...currentSettings.linkCommand, + startupMethod: { + ...currentSettings.linkCommand?.startupMethod, + method: LINK_COMMAND_STARTUP_METHOD.DRAG, + threshold, + }, + }, + }) + + const testPage = new TestPage(page) + await testPage.open() + + const link = page.locator("a[href]").first() + await expect(link).toBeVisible() + + const box = await link.boundingBox() + expect(box).toBeTruthy() + const x = box!.x + box!.width / 2 + const y = box!.y + box!.height / 2 + + // --- Negative: drag below threshold should NOT open a preview --- + const pageCountBefore = context.pages().length + + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x, y + threshold - 10, { steps: 5 }) + await page.mouse.up() + + // Wait briefly to confirm no popup appeared + await page.waitForTimeout(500) + expect(context.pages()).toHaveLength(pageCountBefore) + + // --- Positive: drag above threshold SHOULD open a preview --- + const [previewPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 5000 }), + (async () => { + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x, y + threshold + 10, { steps: 5 }) + await page.mouse.up() + })(), + ]) + + await previewPage.waitForLoadState("domcontentloaded") + expect(previewPage.url()).not.toBe("") + expect(previewPage.url()).not.toContain("about:blank") + }) +}) diff --git a/packages/extension/e2e/pages/OptionsPage.ts b/packages/extension/e2e/pages/OptionsPage.ts index d2cc7fe1..6b50036c 100644 --- a/packages/extension/e2e/pages/OptionsPage.ts +++ b/packages/extension/e2e/pages/OptionsPage.ts @@ -193,6 +193,34 @@ export class OptionsPage { await page.waitForTimeout(700) } + /** + * Change the linkCommand.openMode via UI selection. + * Scrolls to the linkCommand section, opens the select dropdown, + * picks the given mode, and waits for the auto-save debounce. + */ + async setLinkCommandOpenMode(mode: string): Promise { + if (!this.page) { + await this.open() + if (!this.page) { + throw new Error("Failed to open options page") + } + } + const page = this.page + + const trigger = page.locator( + `[data-testid="${TEST_IDS.selectTrigger("linkCommand.openMode")}"]`, + ) + await trigger.scrollIntoViewIfNeeded() + await trigger.click() + + const item = page.locator(`[data-testid="${TEST_IDS.selectItem(mode)}"]`) + await item.waitFor({ state: "visible", timeout: 3000 }) + await item.click() + + // Wait for the 500ms auto-save debounce to flush to storage + await page.waitForTimeout(700) + } + /** * Reset settings to defaults via the Reset button and confirm the dialog. */ diff --git a/packages/extension/e2e/utils/logConsole.ts b/packages/extension/e2e/utils/logConsole.ts new file mode 100644 index 00000000..35b3f931 --- /dev/null +++ b/packages/extension/e2e/utils/logConsole.ts @@ -0,0 +1,34 @@ +import type { ConsoleMessage, Page, Worker } from "@playwright/test" + +function attachConsoleListener(target: Page | Worker, prefix: string): void { + target.on("console", async (msg: ConsoleMessage) => { + if (!process.env.PWDEBUG) return + try { + const type = msg.type().charAt(0).toUpperCase() + const location = msg.location() + const header = `${prefix}[${type}]` + const footer = `@ ${location.url}:${location.lineNumber}` + + const args = await Promise.all(msg.args().map((a) => a.jsonValue())) + const formatted = args.map((v) => + typeof v === "string" ? v : JSON.stringify(v, null, 2), + ) + + console.log(header, " ", ...formatted, "\n ", footer) + } catch (e) { + console.log( + `${prefix}[${msg.type().charAt(0).toUpperCase()}]:`, + msg.text(), + ) + console.warn(`Failed to process console message from ${prefix}`) + } + }) +} + +export function attachConsole(page: Page): void { + attachConsoleListener(page, "Browser") +} + +export function attachSWConsole(sw: Worker): void { + attachConsoleListener(sw, "SW") +} diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts index 4dd4d7fb..18eb6c19 100644 --- a/packages/extension/src/action/aiPrompt.ts +++ b/packages/extension/src/action/aiPrompt.ts @@ -1,5 +1,5 @@ import { Ipc, BgCommand, SidePanelPendingAction } from "@/services/ipc" -import { getScreenSize, getWindowPosition } from "@/services/screen" +import { getWindowPosition } from "@/services/screen" import { isValidString, generateRandomID } from "@/lib/utils" import { OPEN_MODE, @@ -156,7 +156,6 @@ export const AiPrompt = { : baseMode const windowPosition = await getWindowPosition() - const screen = await getScreenSize() const url: UrlParam = { searchUrl: service.url, @@ -172,7 +171,6 @@ export const AiPrompt = { left: Math.floor(windowPosition.left + position.x), height: command.popupOption?.height ?? PopupOption.height, width: command.popupOption?.width ?? PopupOption.width, - screen, selectedText: selectionText, srcUrl: location.href, openMode, diff --git a/packages/extension/src/action/helper.ts b/packages/extension/src/action/helper.ts index 11a60524..ae3b62d6 100644 --- a/packages/extension/src/action/helper.ts +++ b/packages/extension/src/action/helper.ts @@ -8,6 +8,7 @@ import { updateSidePanelUrl as _updateSidePanelUrl, OpenPopupsProps, OpenPopupProps, + OpenPopupAndClickProps, OpenTabProps, OpenSidePanelProps, } from "@/services/chrome" @@ -20,10 +21,6 @@ import type { CommandVariable } from "@/types" type Sender = chrome.runtime.MessageSender -type OpenPopupAndClickProps = OpenPopupProps & { - selector: string -} - type execApiProps = { url: string pageUrl: string @@ -43,7 +40,7 @@ export const openPopup = ( await openPopupWindow(param) response(true) } catch (error) { - console.error("Failed to execute openPopups:", error) + console.error("Failed to execute openPopupWindow:", error) response(false) } }) @@ -60,7 +57,7 @@ export const openPopups = ( await openPopupWindowMultiple(param) response(true) } catch (error) { - console.error("Failed to execute openPopups:", error) + console.error("Failed to execute openPopupWindowMultiple:", error) response(false) } }) diff --git a/packages/extension/src/action/linkPreview.ts b/packages/extension/src/action/linkPreview.ts index 709dec44..71efa55b 100644 --- a/packages/extension/src/action/linkPreview.ts +++ b/packages/extension/src/action/linkPreview.ts @@ -4,11 +4,14 @@ import { findClickableElement, getSelectorFromElement, } from "@/services/dom" -import { getScreenSize } from "@/services/screen" import { DRAG_OPEN_MODE, POPUP_TYPE } from "@/const" import { isEmpty } from "@/lib/utils" import type { ExecuteCommandParams } from "@/types" -import type { OpenSidePanelProps } from "@/services/chrome" +import type { + OpenPopupsProps, + OpenSidePanelProps, + OpenPopupAndClickProps, +} from "@/services/chrome" export const LinkPreview = { async execute({ command, position, target }: ExecuteCommandParams) { @@ -32,14 +35,13 @@ export const LinkPreview = { : POPUP_TYPE.NORMAL if (!isEmpty(href)) { - Ipc.send(BgCommand.openPopups, { + Ipc.send(BgCommand.openPopups, { commandId: command.id, urls: [href], top: Math.floor(position.y), left: Math.floor(position.x), - height: command.popupOption?.height, - width: command.popupOption?.width, - screen: await getScreenSize(), + height: command.popupOption?.height ?? 0, + width: command.popupOption?.width ?? 0, type, }) return @@ -48,16 +50,16 @@ export const LinkPreview = { console.warn("Href not found, trying to find clickable element") const clickElm = findClickableElement(target) + if (clickElm) { const selector = getSelectorFromElement(clickElm) - Ipc.send(BgCommand.openPopupAndClick, { + Ipc.send(BgCommand.openPopupAndClick, { commandId: command.id, - urls: [location.href], + url: location.href, top: Math.floor(position.y), left: Math.floor(position.x), - height: command.popupOption?.height, - width: command.popupOption?.width, - screen: await getScreenSize(), + height: command.popupOption?.height ?? 0, + width: command.popupOption?.width ?? 0, selector, type, }) diff --git a/packages/extension/src/action/pageAction.ts b/packages/extension/src/action/pageAction.ts index 9cb1cefc..f6651301 100644 --- a/packages/extension/src/action/pageAction.ts +++ b/packages/extension/src/action/pageAction.ts @@ -1,5 +1,5 @@ import { Ipc, BgCommand } from "@/services/ipc" -import { getScreenSize, getWindowPosition } from "@/services/screen" +import { getWindowPosition } from "@/services/screen" import { isValidString, isPageActionCommand } from "@/lib/utils" import { PAGE_ACTION_OPEN_MODE, PAGE_ACTION_EVENT } from "@/const" import { PopupOption } from "@/services/option/defaultSettings" @@ -57,7 +57,6 @@ export const PageAction = { : command.pageActionOption.openMode const windowPosition = await getWindowPosition() - const screen = await getScreenSize() Ipc.send(BgCommand.openAndRunPageAction, { commandId: command.id, @@ -68,7 +67,6 @@ export const PageAction = { left: Math.floor(windowPosition.left + position.x), height: command.popupOption?.height ?? PopupOption.height, width: command.popupOption?.width ?? PopupOption.width, - screen, selectedText: selectionText, srcUrl: location.href, openMode, diff --git a/packages/extension/src/action/popup.ts b/packages/extension/src/action/popup.ts index 8da9ca58..744a0ff7 100644 --- a/packages/extension/src/action/popup.ts +++ b/packages/extension/src/action/popup.ts @@ -1,6 +1,6 @@ import { Ipc, BgCommand } from "@/services/ipc" import { isValidString } from "@/lib/utils" -import { getScreenSize, getWindowPosition } from "@/services/screen" +import { getWindowPosition } from "@/services/screen" import { POPUP_TYPE, SPACE_ENCODING } from "@/const" import { PopupOption } from "@/services/option/defaultSettings" import type { ExecuteCommandParams } from "@/types" @@ -23,7 +23,6 @@ export const Popup = { } const windowPosition = await getWindowPosition() - const screen = await getScreenSize() Ipc.send(BgCommand.openPopup, { commandId: command.id, @@ -37,7 +36,6 @@ export const Popup = { left: Math.floor(windowPosition.left + position.x), height: command.popupOption?.height ?? PopupOption.height, width: command.popupOption?.width ?? PopupOption.width, - screen, type: POPUP_TYPE.POPUP, }) }, diff --git a/packages/extension/src/action/selectedLinkPopup.ts b/packages/extension/src/action/selectedLinkPopup.ts index 273b277b..8fd1e620 100644 --- a/packages/extension/src/action/selectedLinkPopup.ts +++ b/packages/extension/src/action/selectedLinkPopup.ts @@ -1,19 +1,20 @@ import { Ipc, BgCommand } from "@/services/ipc" import { linksInSelection } from "@/services/dom" -import { getScreenSize } from "@/services/screen" +import { OpenPopupsProps } from "@/services/chrome" +import { POPUP_TYPE } from "@/const" import type { ExecuteCommandParams } from "@/types" export const SelectedLinkPopup = { async execute({ command, position }: ExecuteCommandParams) { if (position) { - Ipc.send(BgCommand.openPopups, { + Ipc.send(BgCommand.openPopups, { commandId: command.id, urls: linksInSelection(), top: Math.floor(window.screenTop + position.y), left: Math.floor(window.screenLeft + position.x + 20), - height: command.popupOption?.height, - width: command.popupOption?.width, - screen: await getScreenSize(), + height: command.popupOption?.height ?? 0, + width: command.popupOption?.width ?? 0, + type: POPUP_TYPE.POPUP, }) } }, diff --git a/packages/extension/src/action/window.ts b/packages/extension/src/action/window.ts index 65d08e66..f73dee71 100644 --- a/packages/extension/src/action/window.ts +++ b/packages/extension/src/action/window.ts @@ -1,6 +1,6 @@ import { Ipc, BgCommand } from "@/services/ipc" import { isValidString } from "@/lib/utils" -import { getScreenSize, getWindowPosition } from "@/services/screen" +import { getWindowPosition } from "@/services/screen" import { POPUP_TYPE, SPACE_ENCODING } from "@/const" import { PopupOption } from "@/services/option/defaultSettings" import type { OpenPopupProps } from "@/services/chrome" @@ -23,7 +23,6 @@ export const Window = { } const windowPosition = await getWindowPosition() - const screen = await getScreenSize() Ipc.send(BgCommand.openPopup, { commandId: command.id, @@ -37,7 +36,6 @@ export const Window = { left: Math.floor(windowPosition.left + position.x), height: command.popupOption?.height ?? PopupOption.height, width: command.popupOption?.width ?? PopupOption.width, - screen, type: POPUP_TYPE.NORMAL, windowState: command.windowState, }) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 57621c7d..858bb080 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -24,7 +24,6 @@ import { execute } from "@/action/background" import * as ActionHelper from "@/action/helper" import type { WindowType } from "@/types" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" -import { updateActiveScreenId } from "@/services/screen" import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" import { importIf } from "@import-if" @@ -402,9 +401,6 @@ chrome.windows.onFocusChanged.addListener(async (windowId: number) => { return } - // Update active screen ID - await updateActiveScreenId(windowId) - // Update active tab ID await updateActiveTabId() @@ -453,7 +449,6 @@ chrome.tabs.onActivated.addListener(async (activeInfo) => { } try { - await updateActiveScreenId(activeInfo.windowId) await updateActiveTabId(activeInfo.tabId) } catch (error) { console.error("Failed to get active screen ID:", error) diff --git a/packages/extension/src/components/App.tsx b/packages/extension/src/components/App.tsx index 6bd9b169..c71f54e3 100644 --- a/packages/extension/src/components/App.tsx +++ b/packages/extension/src/components/App.tsx @@ -15,8 +15,11 @@ import { SelectContextProvider } from "@/providers/SelectContextProvider" import { TabContextProvider } from "@/providers/TabContextProvider" import { Ipc, TabCommand } from "@/services/ipc" import { Settings } from "@/services/settings/settings" +import { BgData } from "@/services/backgroundData" import type { ShowToastParam } from "@/types" +BgData.init() + type Props = { rootElm: HTMLElement } diff --git a/packages/extension/src/components/option/field/SelectField.tsx b/packages/extension/src/components/option/field/SelectField.tsx index f7060a88..2133322a 100644 --- a/packages/extension/src/components/option/field/SelectField.tsx +++ b/packages/extension/src/components/option/field/SelectField.tsx @@ -14,6 +14,7 @@ import { SelectValue, } from "@/components/ui/select" import { MenuImage } from "@/components/menu/MenuImage" +import { TEST_IDS } from "@/testIds" export type SelectOptionType = { name: string @@ -64,6 +65,7 @@ const renderOption = (opt: SelectOptionType) => { key={opt.value} className={`${opt.isGroup ? "pointer-events-none" : "hover:bg-gray-100"}`} style={{ paddingLeft }} + data-testid={TEST_IDS.selectItem(opt.value)} > {renderOptionContent(opt)} @@ -92,7 +94,10 @@ export const SelectField = ({