From e9d6de049b5b7afaf19ece3d045d270701fbc0b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:49:29 +0000 Subject: [PATCH 1/7] Initial plan From 3b98095735cf6d5dd48b613da7c9c0e2ef0848f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:00:13 +0000 Subject: [PATCH 2/7] Add Playwright E2E test for Chrome extension injection verification Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- .gitignore | 5 ++++ package.json | 1 + packages/extension/e2e/extension.spec.ts | 14 +++++++++++ packages/extension/e2e/fixtures.ts | 32 ++++++++++++++++++++++++ packages/extension/eslint.config.mjs | 7 ++++++ packages/extension/package.json | 6 +++-- packages/extension/playwright.config.ts | 7 ++++++ yarn.lock | 26 +++++++++++++++++++ 8 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 packages/extension/e2e/extension.spec.ts create mode 100644 packages/extension/e2e/fixtures.ts create mode 100644 packages/extension/playwright.config.ts diff --git a/.gitignore b/.gitignore index 5cb11675..dd31d090 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,11 @@ yarn-error.log* # Sentry Config File .env.sentry-build-plugin +# Playwright +**/playwright-report +**/test-results +**/playwright/.cache + # Serena .serena/cache diff --git a/package.json b/package.json index 343f1c12..862068da 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.7.0", "@testing-library/react": "^16.3.0", "@types/node": "^20.10.6", diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts new file mode 100644 index 00000000..30a39b09 --- /dev/null +++ b/packages/extension/e2e/extension.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from "./fixtures" + +const TEST_URL = "https://ujiro99.github.io/selection-command/en/test" +const APP_ID = "selection-command" + +/** + * E2E-01: Verify that the extension content script is injected into the test page. + * Checks that the root element with APP_ID exists in the DOM. + */ +test("E2E-01: extension is injected into the test page", async ({ page }) => { + await page.goto(TEST_URL) + const appElement = page.locator(`#${APP_ID}`) + await expect(appElement).toBeAttached() +}) diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts new file mode 100644 index 00000000..010c183e --- /dev/null +++ b/packages/extension/e2e/fixtures.ts @@ -0,0 +1,32 @@ +import { test as base, chromium, type BrowserContext } from "@playwright/test" +import path from "path" + +const pathToExtension = path.join(__dirname, "../dist") + +/** + * Custom test fixture that launches Chrome with the extension loaded. + */ +export const test = base.extend<{ context: BrowserContext }>({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + const context = await chromium.launchPersistentContext("", { + // headless: false is required so Playwright doesn't restrict extension loading. + // --headless=new enables Chrome's new headless mode that supports extensions, + // allowing tests to run in CI without a display. + headless: false, + args: [ + "--headless=new", + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }) + await use(context) + await context.close() + }, + page: async ({ context }, use) => { + const page = await context.newPage() + await use(page) + }, +}) + +export const expect = test.expect diff --git a/packages/extension/eslint.config.mjs b/packages/extension/eslint.config.mjs index ed2d340c..bb94d85a 100644 --- a/packages/extension/eslint.config.mjs +++ b/packages/extension/eslint.config.mjs @@ -16,4 +16,11 @@ export default tseslint.config(...rootConfig, { { allowConstantExport: true }, ], }, +}, +{ + // Playwright fixtures use a `use` callback that conflicts with React hooks rules + files: ["e2e/**/*.{ts,tsx}"], + rules: { + "react-hooks/rules-of-hooks": "off", + }, }) diff --git a/packages/extension/package.json b/packages/extension/package.json index 6ec648e8..4380c1d2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -14,7 +14,8 @@ "test:coverage": "vitest --coverage", "pretty-quick": "pretty-quick", "precommit": "pretty-quick --staged", - "zip": "npm-build-zip --source=dist --destination=build" + "zip": "npm-build-zip --source=dist --destination=build", + "test:e2e": "playwright test" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -77,7 +78,8 @@ "vite": "^6.0.5", "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-macro": "^0.2.0", - "webextension-polyfill": "^0.10.0" + "webextension-polyfill": "^0.10.0", + "@playwright/test": "^1.58.2" }, "browserslist": { "production": [ diff --git a/packages/extension/playwright.config.ts b/packages/extension/playwright.config.ts new file mode 100644 index 00000000..f831f076 --- /dev/null +++ b/packages/extension/playwright.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./e2e", + timeout: 30000, + retries: 1, +}) diff --git a/yarn.lock b/yarn.lock index f6bd9642..ed719738 100644 --- a/yarn.lock +++ b/yarn.lock @@ -914,6 +914,13 @@ resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz" integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== +"@playwright/test@^1.58.2": + version "1.58.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.58.2.tgz#b0ad585d2e950d690ef52424967a42f40c6d2cbd" + integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== + dependencies: + playwright "1.58.2" + "@polka/url@^1.0.0-next.24": version "1.0.0-next.29" resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz" @@ -4237,6 +4244,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" @@ -6214,6 +6226,20 @@ platform@^1.3.6: resolved "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz" integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== +playwright-core@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.58.2.tgz#ac5f5b4b10d29bcf934415f0b8d133b34b0dcb13" + integrity sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg== + +playwright@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.58.2.tgz#afe547164539b0bcfcb79957394a7a3fa8683cfd" + integrity sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A== + dependencies: + playwright-core "1.58.2" + optionalDependencies: + fsevents "2.3.2" + possible-typed-array-names@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz" From edd97e814c44440bedc5d0806d3dcfcc5be5a818 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 25 Feb 2026 12:49:30 +0900 Subject: [PATCH 3/7] Update: Add a github action for e2e testing. --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ package.json | 1 + 2 files changed, 26 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c628c32..ada71a21 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,3 +40,28 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false + + e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + cache: "yarn" + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build extension + run: yarn build:extension + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Run E2E tests + run: yarn test:e2e diff --git a/package.json b/package.json index 862068da..f1d776d3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint": "yarn workspaces run lint", "test": "yarn workspace @selection-command/extension test:run; yarn workspace @selection-command/hub test:run", "test:coverage": "yarn workspaces run test:coverage", + "test:e2e": "yarn workspace @selection-command/extension test:e2e", "tsc": "yarn workspaces run tsc -b", "clean": "yarn workspaces run clean && rm -rf node_modules", "add-command": "yarn workspace @selection-command/hub add-command" From 194c17d237b04e745bdb759d76c71fd92e18542d Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 26 Feb 2026 06:01:10 +0900 Subject: [PATCH 4/7] Update: Add E2E-02. --- .claude/settings.local.json | 3 +- packages/extension/e2e/extension.spec.ts | 29 +++++++-- packages/extension/e2e/fixtures.ts | 5 +- packages/extension/e2e/pages/TestPage.ts | 75 ++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 packages/extension/e2e/pages/TestPage.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e361b980..9fd707fd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -60,7 +60,8 @@ "mcp__serena__think_about_collected_information", "mcp__serena__think_about_task_adherence", "mcp__serena__think_about_whether_you_are_done", - "mcp__serena__write_memory" + "mcp__serena__write_memory", + "WebFetch(domain:ujiro99.github.io)" ], "deny": [] } diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index 30a39b09..eb55a82f 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -1,14 +1,31 @@ import { test, expect } from "./fixtures" - -const TEST_URL = "https://ujiro99.github.io/selection-command/en/test" -const APP_ID = "selection-command" +import { TestPage } from "./pages/TestPage" /** * E2E-01: Verify that the extension content script is injected into the test page. * Checks that the root element with APP_ID exists in the DOM. */ test("E2E-01: extension is injected into the test page", async ({ page }) => { - await page.goto(TEST_URL) - const appElement = page.locator(`#${APP_ID}`) - await expect(appElement).toBeAttached() + 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. + * + * The popup is rendered via Radix UI's portal into document.body (outside shadow DOM), + * so it is directly queryable. It appears with data-state="open" after the popup delay. + */ +test("E2E-02: popup menu appears on text selection", async ({ page }) => { + const testPage = new TestPage(page) + await testPage.open() + + await testPage.selectText() + + // The popup menu portals to document.body via Radix UI. + // It becomes visible with data-state="open" after the popup delay (~250ms). + await expect(page.locator('[data-state="open"]')).toBeVisible({ + timeout: 3000, + }) }) diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index 010c183e..9aa09ebd 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -9,13 +9,16 @@ const pathToExtension = path.join(__dirname, "../dist") export const test = base.extend<{ context: BrowserContext }>({ // eslint-disable-next-line no-empty-pattern 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("", { // headless: false is required so Playwright doesn't restrict extension loading. // --headless=new enables Chrome's new headless mode that supports extensions, // allowing tests to run in CI without a display. headless: false, args: [ - "--headless=new", + // Omit --headless=new in debug mode so the browser window is visible. + ...(!isDebug ? ["--headless=new"] : []), `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts new file mode 100644 index 00000000..c55357db --- /dev/null +++ b/packages/extension/e2e/pages/TestPage.ts @@ -0,0 +1,75 @@ +import { expect, type Page } from "@playwright/test" + +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) {} + + /** + * 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() + } + + /** + * Programmatically select text on the page and dispatch the events the + * extension listens for (selectionchange + dblclick). + * + * Using dblclick() alone in headless mode does not reliably trigger the + * browser's native text selection, so document.getSelection() stays empty + * and the extension's onDouble handler skips setAnchor(). Instead, we use + * the Selection API to create the selection explicitly. + */ + async selectText(): Promise { + await this.page.evaluate(() => { + const heading = document.querySelector("h1, h2, h3") + if (!heading) throw new Error("No heading element found") + + // Scroll into view so getBoundingClientRect() returns valid viewport coordinates. + heading.scrollIntoView() + + // Find the first non-empty text node to build a selection range. + const textNode = Array.from(heading.childNodes).find( + (n) => + n.nodeType === Node.TEXT_NODE && + (n.textContent?.trim().length ?? 0) > 0, + ) + if (!textNode) throw new Error("No text node found in heading") + + const text = textNode.textContent ?? "" + const spaceIndex = text.trimStart().indexOf(" ") + const wordEnd = spaceIndex > 0 ? spaceIndex : text.length + + // Set the selection range covering the first word. + const range = document.createRange() + range.setStart(textNode, 0) + range.setEnd(textNode, wordEnd) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + // Notify SelectContextProvider so it updates its selectionText state. + document.dispatchEvent(new Event("selectionchange")) + + // 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( + new MouseEvent("dblclick", { + bubbles: true, + cancelable: true, + button: 0, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ) + }) + } +} From 2ab8ff402c1274733dd0afaeb51fef64a4556fa5 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 26 Feb 2026 07:27:30 +0900 Subject: [PATCH 5/7] Fix: tests. --- packages/extension/e2e/extension.spec.ts | 6 +- packages/extension/e2e/pages/TestPage.ts | 106 ++++++++++++++--------- packages/extension/vitest.config.ts | 1 + 3 files changed, 67 insertions(+), 46 deletions(-) diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index eb55a82f..74f3bee4 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -23,9 +23,9 @@ test("E2E-02: popup menu appears on text selection", async ({ page }) => { await testPage.selectText() - // The popup menu portals to document.body via Radix UI. - // It becomes visible with data-state="open" after the popup delay (~250ms). + // selectText() already waits until [data-state="open"] appears, so a short + // timeout is sufficient here. await expect(page.locator('[data-state="open"]')).toBeVisible({ - timeout: 3000, + timeout: 1000, }) }) diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index c55357db..354a94ea 100644 --- a/packages/extension/e2e/pages/TestPage.ts +++ b/packages/extension/e2e/pages/TestPage.ts @@ -19,57 +19,77 @@ export class TestPage { } /** - * Programmatically select text on the page and dispatch the events the - * extension listens for (selectionchange + dblclick). + * Programmatically select text on the page and wait until the extension's + * popup menu appears. * - * Using dblclick() alone in headless mode does not reliably trigger the - * browser's native text selection, so document.getSelection() stays empty - * and the extension's onDouble handler skips setAnchor(). Instead, we use - * the Selection API to create the selection explicitly. + * The extension registers its dblclick/selectionchange listeners inside React + * useEffect hooks, which run asynchronously after the component mounts. In CI + * 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: + * 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. + * + * 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(): Promise { - await this.page.evaluate(() => { - const heading = document.querySelector("h1, h2, h3") - if (!heading) throw new Error("No heading element found") + await this.page.waitForFunction( + () => { + const heading = document.querySelector("h1, h2, h3") + if (!heading) return false + + // Scroll into view so getBoundingClientRect() returns valid coordinates. + heading.scrollIntoView() - // Scroll into view so getBoundingClientRect() returns valid viewport coordinates. - heading.scrollIntoView() + // Find the first non-empty text node to build a selection range. + const textNode = Array.from(heading.childNodes).find( + (n) => + n.nodeType === Node.TEXT_NODE && + (n.textContent?.trim().length ?? 0) > 0, + ) + if (!textNode) return false - // Find the first non-empty text node to build a selection range. - const textNode = Array.from(heading.childNodes).find( - (n) => - n.nodeType === Node.TEXT_NODE && - (n.textContent?.trim().length ?? 0) > 0, - ) - if (!textNode) throw new Error("No text node found in heading") + const text = textNode.textContent ?? "" + const spaceIndex = text.trimStart().indexOf(" ") + const wordEnd = spaceIndex > 0 ? spaceIndex : text.length - const text = textNode.textContent ?? "" - const spaceIndex = text.trimStart().indexOf(" ") - const wordEnd = spaceIndex > 0 ? spaceIndex : text.length + // Set the selection range covering the first word. + const range = document.createRange() + range.setStart(textNode, 0) + range.setEnd(textNode, wordEnd) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) - // Set the selection range covering the first word. - const range = document.createRange() - range.setStart(textNode, 0) - range.setEnd(textNode, wordEnd) - const selection = window.getSelection()! - selection.removeAllRanges() - selection.addRange(range) + // Notify SelectContextProvider so it updates its selectionText state. + document.dispatchEvent(new Event("selectionchange")) - // Notify SelectContextProvider so it updates its selectionText state. - document.dispatchEvent(new Event("selectionchange")) + // 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( + new MouseEvent("dblclick", { + bubbles: true, + cancelable: true, + button: 0, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ) - // 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( - new MouseEvent("dblclick", { - bubbles: true, - cancelable: true, - button: 0, - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - }), - ) - }) + // The popup portals into document.body via Radix UI. It appears after a + // ~250ms delay. Polling at 500ms gives the popup time to render before + // the next check, without requiring any external library internals. + return !!document.querySelector('[data-state="open"]') + }, + undefined, + { polling: 500, timeout: 10_000 }, + ) } } diff --git a/packages/extension/vitest.config.ts b/packages/extension/vitest.config.ts index 3db6d1c4..ce33c459 100644 --- a/packages/extension/vitest.config.ts +++ b/packages/extension/vitest.config.ts @@ -8,6 +8,7 @@ export default mergeConfig( defineConfig({ test: { setupFiles: ["./src/test/setup.ts"], + exclude: ["**/node_modules/**", "**/e2e/**"], }, define: { __APP_NAME__: JSON.stringify(packageJson.name), From aafec2f2245a9b37ab1072ab4123cf19dde35def Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 26 Feb 2026 11:57:39 +0900 Subject: [PATCH 6/7] Update: Fix tests. --- .github/workflows/test.yml | 2 +- package.json | 1 + packages/extension/e2e/extension.spec.ts | 7 ++--- packages/extension/e2e/pages/TestPage.ts | 21 ++++++++++----- packages/extension/package.json | 4 +-- .../extension/src/components/menu/Menu.tsx | 26 ++++++++++--------- packages/extension/src/const.ts | 1 + packages/extension/src/content_script.tsx | 4 +-- packages/extension/src/testIds.ts | 3 +++ packages/extension/tsconfig.e2e.json | 12 +++++++++ packages/extension/tsconfig.json | 3 +++ 11 files changed, 56 insertions(+), 28 deletions(-) create mode 100644 packages/extension/src/testIds.ts create mode 100644 packages/extension/tsconfig.e2e.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ada71a21..83aac1a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,7 +58,7 @@ jobs: run: yarn install --frozen-lockfile - name: Build extension - run: yarn build:extension + run: yarn build:e2e - name: Install Playwright browsers run: npx playwright install chromium --with-deps diff --git a/package.json b/package.json index f1d776d3..1ffcf792 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:hub": "yarn workspace @selection-command/hub dev", "build": "yarn workspaces run build", "build:extension": "yarn workspace @selection-command/extension build", + "build:e2e": "yarn workspace @selection-command/extension build:e2e", "build:hub": "yarn workspace @selection-command/hub build", "lint": "yarn workspaces run lint", "test": "yarn workspace @selection-command/extension test:run; yarn workspace @selection-command/hub test:run", diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index 74f3bee4..73197711 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -23,9 +23,6 @@ test("E2E-02: popup menu appears on text selection", async ({ page }) => { await testPage.selectText() - // selectText() already waits until [data-state="open"] appears, so a short - // timeout is sufficient here. - await expect(page.locator('[data-state="open"]')).toBeVisible({ - timeout: 1000, - }) + const menubar = await testPage.getMenuBar() + expect(menubar.isVisible()) }) diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts index 354a94ea..789a1a8b 100644 --- a/packages/extension/e2e/pages/TestPage.ts +++ b/packages/extension/e2e/pages/TestPage.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test" +import { TEST_IDS } from "@/testIds" const TEST_URL = "https://ujiro99.github.io/selection-command/en/test" const APP_ID = "selection-command" @@ -40,7 +41,7 @@ export class TestPage { */ async selectText(): Promise { await this.page.waitForFunction( - () => { + (testIds) => { const heading = document.querySelector("h1, h2, h3") if (!heading) return false @@ -84,12 +85,20 @@ export class TestPage { ) // The popup portals into document.body via Radix UI. It appears after a - // ~250ms delay. Polling at 500ms gives the popup time to render before - // the next check, without requiring any external library internals. - return !!document.querySelector('[data-state="open"]') + // ~250ms delay. Polling at 300ms gives the popup time to render before + // the next check. + const el = document.getElementById(testIds.appId) + return ( + el?.shadowRoot?.querySelector(`[data-testid='${testIds.menuBar}']`) != + null + ) }, - undefined, - { polling: 500, timeout: 10_000 }, + { ...TEST_IDS, appId: APP_ID }, + { polling: 300, timeout: 10_000 }, ) } + + async getMenuBar(): Promise> { + return this.page.locator(`[data-testid="${TEST_IDS.menuBar}"]`) + } } diff --git a/packages/extension/package.json b/packages/extension/package.json index 4380c1d2..5cdcbc15 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:e2e": "tsc -b && vite build --mode e2e", "lint": "eslint .", "test": "vitest", "test:ui": "vitest --ui", @@ -78,8 +79,7 @@ "vite": "^6.0.5", "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-macro": "^0.2.0", - "webextension-polyfill": "^0.10.0", - "@playwright/test": "^1.58.2" + "webextension-polyfill": "^0.10.0" }, "browserslist": { "production": [ diff --git a/packages/extension/src/components/menu/Menu.tsx b/packages/extension/src/components/menu/Menu.tsx index a80e3cec..b2270d87 100644 --- a/packages/extension/src/components/menu/Menu.tsx +++ b/packages/extension/src/components/menu/Menu.tsx @@ -8,6 +8,7 @@ import { import { ScrollAreaConditional } from "@/components/ui/scroll-area" import { STYLE, SIDE } from "@/const" +import { TEST_IDS } from "@/testIds" import { MenuItem } from "./MenuItem" import { Icon } from "@/components/Icon" import { HoverArea } from "@/components/menu/HoverArea" @@ -50,6 +51,7 @@ export function Menu(): JSX.Element { [css.menuVertical]: !isHorizontal, })} ref={menuRef} + data-testid={TEST_IDS.menuBar} > {commandTree.map((node) => ( diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index e120af6e..847d8546 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -10,6 +10,7 @@ export const VERSION = __APP_VERSION__ as string */ const environment = import.meta.env.MODE ?? "development" export const isDebug = environment === "development" +export const isE2E = environment === "e2e" // Abstract command types for simplified command creation export enum COMMAND_TYPE { diff --git a/packages/extension/src/content_script.tsx b/packages/extension/src/content_script.tsx index 27400982..112dfcdb 100644 --- a/packages/extension/src/content_script.tsx +++ b/packages/extension/src/content_script.tsx @@ -1,5 +1,5 @@ import { createRoot } from "react-dom/client" -import { APP_ID, isDebug } from "./const" +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" @@ -14,7 +14,7 @@ try { const rootDom = document.createElement("div") rootDom.id = APP_ID document.body.insertAdjacentElement("afterend", rootDom) - const mode = isDebug ? "open" : "closed" // 'open' for debugging + const mode = isDebug || isE2E ? "open" : "closed" // 'open' for debugging and e2e const shadow = rootDom.attachShadow({ mode }) shadow.innerHTML = icons const root = createRoot(shadow) diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts new file mode 100644 index 00000000..fb073032 --- /dev/null +++ b/packages/extension/src/testIds.ts @@ -0,0 +1,3 @@ +export const TEST_IDS = { + menuBar: "menu-bar", +} diff --git a/packages/extension/tsconfig.e2e.json b/packages/extension/tsconfig.e2e.json new file mode 100644 index 00000000..b14e7e36 --- /dev/null +++ b/packages/extension/tsconfig.e2e.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.e2e.tsbuildinfo", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "noEmit": true + }, + "include": ["playwright.config.ts", "e2e/**/*.ts"] +} diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index ea9d0cd8..371e29bc 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -6,6 +6,9 @@ }, { "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.e2e.json" } ] } From 2ca3559b52ffd8bd6d6a94affb25785a3c74c230 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 26 Feb 2026 12:07:21 +0900 Subject: [PATCH 7/7] Update: review comment. --- packages/extension/e2e/extension.spec.ts | 3 --- packages/extension/playwright.config.ts | 2 ++ 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts index 73197711..508cf14a 100644 --- a/packages/extension/e2e/extension.spec.ts +++ b/packages/extension/e2e/extension.spec.ts @@ -13,9 +13,6 @@ 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. - * - * The popup is rendered via Radix UI's portal into document.body (outside shadow DOM), - * so it is directly queryable. It appears with data-state="open" after the popup delay. */ test("E2E-02: popup menu appears on text selection", async ({ page }) => { const testPage = new TestPage(page) diff --git a/packages/extension/playwright.config.ts b/packages/extension/playwright.config.ts index f831f076..6cbe0971 100644 --- a/packages/extension/playwright.config.ts +++ b/packages/extension/playwright.config.ts @@ -4,4 +4,6 @@ export default defineConfig({ testDir: "./e2e", timeout: 30000, retries: 1, + // Extension tests use launchPersistentContext, which can conflict when run in parallel. + workers: 1, })