Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:e2e

- name: Install Playwright browsers
run: npx playwright install chromium --with-deps

- name: Run E2E tests
run: yarn test:e2e
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ yarn-error.log*
# Sentry Config File
.env.sentry-build-plugin

# Playwright
**/playwright-report
**/test-results
**/playwright/.cache

# Serena
.serena/cache

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@
"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",
"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"
},
"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",
Expand Down
25 changes: 25 additions & 0 deletions packages/extension/e2e/extension.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { test, expect } from "./fixtures"
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 }) => {
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-02: 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())
})
35 changes: 35 additions & 0 deletions packages/extension/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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) => {
// 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: [
// Omit --headless=new in debug mode so the browser window is visible.
...(!isDebug ? ["--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
104 changes: 104 additions & 0 deletions packages/extension/e2e/pages/TestPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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"

/**
* 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<void> {
await this.page.goto(TEST_URL)
await expect(this.page.locator(`#${APP_ID}`)).toBeAttached()
}

/**
* Programmatically select text on the page and wait until the extension's
* popup menu appears.
*
* 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<void> {
await this.page.waitForFunction(
(testIds) => {
const heading = document.querySelector("h1, h2, h3")
if (!heading) return false

// Scroll into view so getBoundingClientRect() returns valid 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

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,
}),
)

// The popup portals into document.body via Radix UI. It appears after a
// ~250ms delay. Polling at 300ms gives the popup time to render before
// the next check.
const el = document.getElementById(testIds.appId)
return (
el?.shadowRoot?.querySelector(`[data-testid='${testIds.menuBar}']`) !=
null
)
},
{ ...TEST_IDS, appId: APP_ID },
{ polling: 300, timeout: 10_000 },
)
}

async getMenuBar(): Promise<ReturnType<Page["locator"]>> {
return this.page.locator(`[data-testid="${TEST_IDS.menuBar}"]`)
}
}
7 changes: 7 additions & 0 deletions packages/extension/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
})
4 changes: 3 additions & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:e2e": "tsc -b && vite build --mode e2e",
"lint": "eslint .",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"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",
Expand Down
9 changes: 9 additions & 0 deletions packages/extension/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test"

export default defineConfig({
testDir: "./e2e",
timeout: 30000,
retries: 1,
// Extension tests use launchPersistentContext, which can conflict when run in parallel.
workers: 1,
})
26 changes: 14 additions & 12 deletions packages/extension/src/components/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -50,6 +51,7 @@ export function Menu(): JSX.Element {
[css.menuVertical]: !isHorizontal,
})}
ref={menuRef}
data-testid={TEST_IDS.menuBar}
>
{commandTree.map((node) => (
<MenuTreeNode
Expand Down Expand Up @@ -189,19 +191,19 @@ const MenuFolder = (props: {
const baseSize = anchorRef.current?.getBoundingClientRect().height ?? 0
const menubarStyle = isHorizontal
? {
maxWidth:
baseSize * 10 /* buttons */ +
1 * 9 /* gap */ +
2 * 2 /* padding */ +
1 * 2 /* border */,
}
maxWidth:
baseSize * 10 /* buttons */ +
1 * 9 /* gap */ +
2 * 2 /* padding */ +
1 * 2 /* border */,
}
: {
maxHeight:
baseSize * 11.5 /* buttons */ +
1 * 10 /* gap */ +
2 * 2 /* padding */ +
1 * 2 /* border */,
}
maxHeight:
baseSize * 11.5 /* buttons */ +
1 * 10 /* gap */ +
2 * 2 /* padding */ +
1 * 2 /* border */,
}

return (
<MenubarMenu value={folder.id}>
Expand Down
1 change: 1 addition & 0 deletions packages/extension/src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/extension/src/content_script.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/src/testIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const TEST_IDS = {
menuBar: "menu-bar",
}
12 changes: 12 additions & 0 deletions packages/extension/tsconfig.e2e.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 3 additions & 0 deletions packages/extension/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
},
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.e2e.json"
}
]
}
1 change: 1 addition & 0 deletions packages/extension/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading