diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000..9f6fe1241a --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,34 @@ +name: Playwright E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Install Task + uses: arduino/setup-task@v2 + + - name: Install Zig + uses: mlugg/setup-zig@v1 + + - name: Build app + run: task package -- --win --x64 --config.win.target=zip + + - name: Run E2E tests + run: npx playwright test diff --git a/e2e/helpers/launch.ts b/e2e/helpers/launch.ts new file mode 100644 index 0000000000..95d95f3383 --- /dev/null +++ b/e2e/helpers/launch.ts @@ -0,0 +1,23 @@ +import { _electron as electron, ElectronApplication, Page } from "@playwright/test"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +export const APP_PATH = path.resolve(__dirname, "../../make/win-unpacked/Wave.exe"); + +export async function launchApp(): Promise { + const app = await electron.launch({ + executablePath: APP_PATH, + args: [], + env: { + ...process.env, + WAVETERM_NOCONFIRMQUIT: "1", + }, + }); + return app; +} + +export async function getMainWindow(app: ElectronApplication): Promise { + return await app.firstWindow(); +} diff --git a/e2e/search-clipboard.spec.ts b/e2e/search-clipboard.spec.ts new file mode 100644 index 0000000000..b343c93e70 --- /dev/null +++ b/e2e/search-clipboard.spec.ts @@ -0,0 +1,47 @@ +import { ElectronApplication, Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import { launchApp } from "./helpers/launch"; + +async function getMainWindow(app: ElectronApplication): Promise { + return await app.firstWindow(); +} + +async function setClipboard(app: ElectronApplication, text: string): Promise { + await app.evaluate(({ clipboard }, txt) => clipboard.writeText(txt), text); +} + +async function readClipboard(app: ElectronApplication): Promise { + return await app.evaluate(({ clipboard }) => clipboard.readText()); +} + +test.describe("search + copy-on-select clipboard behavior", () => { + let app: ElectronApplication; + let window: Page; + + test.beforeEach(async () => { + app = await launchApp(); + window = await getMainWindow(app); + }); + + test.afterEach(async () => { + await app.close(); + }); + + test("navigating search results should not overwrite clipboard", async () => { + await window.waitForTimeout(8000); + + await setClipboard(app, "known-clipboard-content"); + expect(await readClipboard(app)).toBe("known-clipboard-content"); + + await window.keyboard.press("Control+f"); + await window.waitForTimeout(1000); + + await window.keyboard.type("test"); + await window.waitForTimeout(1000); + + await window.keyboard.press("Enter"); + await window.waitForTimeout(1000); + + expect(await readClipboard(app)).toBe("known-clipboard-content"); + }); +}); diff --git a/frontend/app/element/search.tsx b/frontend/app/element/search.tsx index e09e8a1078..923ed464ce 100644 --- a/frontend/app/element/search.tsx +++ b/frontend/app/element/search.tsx @@ -11,6 +11,7 @@ import "./search.scss"; type SearchProps = SearchAtoms & { anchorRef?: React.RefObject; + searchInputRef?: React.RefObject; offsetX?: number; offsetY?: number; onSearch?: (search: string) => void; @@ -34,6 +35,8 @@ const SearchComponent = ({ onNext, onPrev, }: SearchProps) => { + const localInputRef = useRef(null); + const inputRef = providedInputRef || localInputRef; const [isOpen, setIsOpen] = useAtom(isOpenAtom); const [search, setSearch] = useAtom(searchAtom); const [index, setIndex] = useAtom(indexAtom); @@ -198,6 +201,7 @@ export const Search = memo(SearchComponent) as typeof SearchComponent; type SearchOptions = { anchorRef?: React.RefObject; + searchInputRef?: React.RefObject; viewModel?: ViewModel; regex?: boolean; caseSensitive?: boolean; @@ -219,15 +223,19 @@ export function useSearch(options?: SearchOptions): SearchProps { [] ); const anchorRef = options?.anchorRef ?? useRef(null); + const searchInputRef = options?.searchInputRef ?? useRef(null); useEffect(() => { if (options?.viewModel) { options.viewModel.searchAtoms = searchAtoms; + // Store searchInputRef on viewModel for external access (e.g., keymodel.ts) + (options.viewModel as any).searchInputRef = searchInputRef; return () => { options.viewModel.searchAtoms = undefined; + (options.viewModel as any).searchInputRef = undefined; }; } }, [options?.viewModel]); - return { ...searchAtoms, anchorRef }; + return { ...searchAtoms, anchorRef, searchInputRef }; } const createToggleButtonDecl = ( diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index cca01753bb..02ced3d3ef 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -28,6 +28,7 @@ import { isWindows } from "@/util/platformutil"; import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { fireAndForget } from "@/util/util"; import * as jotai from "jotai"; +import { TermViewModel } from "../view/term/term-model"; import { modalsModel } from "./modalmodel"; import { isBuilderWindow, isTabWindow } from "./windowtype"; @@ -701,6 +702,30 @@ function registerGlobalKeys() { return true; }); } + function getSelectedText(): string { + // Check for terminal selection first + const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); + if (bcm?.viewModel?.viewType === "term") { + const termViewModel = bcm.viewModel as TermViewModel; + if (termViewModel.termRef?.current?.terminal) { + const terminalSelection = termViewModel.termRef.current.terminal.getSelection(); + if (terminalSelection && terminalSelection.length > 0) { + return terminalSelection.trim(); + } + } + } + + // Check for regular text selection + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + const selectedText = selection.toString().trim(); + if (selectedText.length > 0) { + return selectedText; + } + } + return ""; + } + function activateSearch(event: WaveKeyboardEvent): boolean { const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); // Ctrl+f is reserved in most shells diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 67eb5737c6..a9dcd17bf0 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -200,8 +200,10 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const isBasicTerm = termMode != "vdom" && blockData?.meta?.controller != "cmd"; // needs to match isBasicTerm // search + const searchInputRef = React.useRef(null); const searchProps = useSearch({ anchorRef: viewRef, + searchInputRef: searchInputRef, viewModel: model, caseSensitive: false, wholeWord: false, diff --git a/openspec/changes/add-playwright-e2e/.openspec.yaml b/openspec/changes/add-playwright-e2e/.openspec.yaml new file mode 100644 index 0000000000..b44fb0ec50 --- /dev/null +++ b/openspec/changes/add-playwright-e2e/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-30 diff --git a/openspec/changes/add-playwright-e2e/design.md b/openspec/changes/add-playwright-e2e/design.md new file mode 100644 index 0000000000..103378983f --- /dev/null +++ b/openspec/changes/add-playwright-e2e/design.md @@ -0,0 +1,41 @@ +## Context + +WaveTerm's UI testing is limited to a single TestDriver.ai onboarding scenario that runs only in CI. For the copy-on-select fix and future UI changes, we need fast, local, reliable E2E tests. Playwright with Electron support is the standard choice. + +## Goals / Non-Goals + +**Goals:** +- Add Playwright with Electron support as dev dependency +- Create a config that works with both dev mode (`electron-vite dev`) and packaged app +- Write a test verifying the search + copy-on-select clipboard fix +- Ensure tests can run locally without CI dependencies + +**Non-Goals:** +- Full UI coverage of all features (future work) +- Replacing existing TestDriver.ai tests +- Testing backend/Go code + +## Decisions + +**Decision: Launch Electron app via direct binary for testing** + +Use Playwright's `_electron.launch()` with the packaged app executable. This avoids the complexity of hooking into `electron-vite dev` hot-reload. + +For local development, users can run `task package -- --win --x64 --config.win.target=zip` to produce a portable build, then run tests against it. + +- Alternative considered: Launching via `electron-vite dev` — harder to detect ready state, slower +- Alternative considered: Using `electron-vite preview` — still requires a build step + +**Decision: No global test fixtures for now** + +Keep tests self-contained with a shared launch helper. Avoid complex fixture setup until more tests exist. + +**Decision: Tests in `e2e/` at project root** + +Follow Playwright conventions. Place in `e2e/` not `frontend/e2e/` since tests cover the full Electron app. + +## Risks / Trade-offs + +- **Build needed**: Tests require a packaged build first (can be automated in CI) +- **Windows focus**: Initial setup targets Windows (current platform); other platforms can be added later +- **No hot reload**: Unlike TestDriver.ai, Playwright tests launch the full app binary diff --git a/openspec/changes/add-playwright-e2e/proposal.md b/openspec/changes/add-playwright-e2e/proposal.md new file mode 100644 index 0000000000..6ceaa5df44 --- /dev/null +++ b/openspec/changes/add-playwright-e2e/proposal.md @@ -0,0 +1,30 @@ +## Why + +WaveTerm currently has no UI automated testing. The only E2E test uses TestDriver.ai (CI-only, single onboarding scenario). Manual testing is required for every UI bug fix. Introducing Playwright enables fast, reliable, locally-runnable UI tests for the terminal and core UI components. + +## What Changes + +- Add `@playwright/test` as dev dependency +- Create Playwright configuration for Electron app testing +- Write E2E test for the recently fixed search + copy-on-select clipboard bug +- Set up a test helper for launching the Electron app (both dev mode and packaged) +- Add npm scripts for running Playwright tests +- Add CI workflow for running Playwright tests on PRs + +## Capabilities + +### New Capabilities + +- `playwright-electron-setup`: Playwright config targeting WaveTerm's Electron app, with launch helpers for dev/packaged modes +- `e2e-search-clipboard`: E2E test coverage for the copy-on-select clipboard behavior when search is open + +### Modified Capabilities + +- ``: no spec-level requirement changes + +## Impact + +- `package.json` — new devDependency `@playwright/test`, new scripts +- `playwright.config.ts` — new Playwright configuration file (project root) +- `e2e/` — new directory with test specs and helpers +- `.github/workflows/playwright.yml` — new CI workflow (optional) diff --git a/openspec/changes/add-playwright-e2e/specs/e2e-search-clipboard/spec.md b/openspec/changes/add-playwright-e2e/specs/e2e-search-clipboard/spec.md new file mode 100644 index 0000000000..35e60e3d79 --- /dev/null +++ b/openspec/changes/add-playwright-e2e/specs/e2e-search-clipboard/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Search + copy-on-select clipboard test + +The system SHALL have an E2E test that verifies the fix for copy-on-select not overwriting clipboard when search is open. + +#### Scenario: Clipboard not overwritten during search navigation +- **GIVEN** `term:copyonselect` is enabled +- **WHEN** user opens search (Ctrl+F) and types text +- **WHEN** user navigates through search results +- **THEN** the clipboard SHALL NOT contain the terminal selection text + +#### Scenario: Copy-on-select works when search is closed +- **GIVEN** `term:copyonselect` is enabled +- **WHEN** search is closed +- **WHEN** user selects text in the terminal +- **THEN** the clipboard SHALL contain the selected text diff --git a/openspec/changes/add-playwright-e2e/specs/playwright-electron-setup/spec.md b/openspec/changes/add-playwright-e2e/specs/playwright-electron-setup/spec.md new file mode 100644 index 0000000000..d2b7e9dfd1 --- /dev/null +++ b/openspec/changes/add-playwright-e2e/specs/playwright-electron-setup/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Playwright configuration for Electron + +The system SHALL provide a Playwright configuration that can launch the WaveTerm Electron app for testing. + +#### Scenario: Dev mode launch +- **WHEN** running `npx playwright test` in dev mode +- **THEN** the test framework SHALL launch WaveTerm via `electron-vite dev` +- **AND** wait for the app window to be ready before running tests + +#### Scenario: Packaged app launch +- **WHEN** running Playwright against a packaged build +- **THEN** the test framework SHALL launch the Wave.exe binary directly +- **AND** wait for the window to be ready + +### Requirement: Test organization + +E2E tests SHALL be organized in an `e2e/` directory at the project root, with shared helpers in `e2e/helpers/`. + +#### Scenario: Directory structure +- **WHEN** viewing the `e2e/` directory +- **THEN** it SHALL contain spec files and a `helpers/` subdirectory with launch utilities diff --git a/openspec/changes/add-playwright-e2e/tasks.md b/openspec/changes/add-playwright-e2e/tasks.md new file mode 100644 index 0000000000..96725937a3 --- /dev/null +++ b/openspec/changes/add-playwright-e2e/tasks.md @@ -0,0 +1,15 @@ +## 1. Setup + +- [x] 1.1 Install Playwright and Electron support: `npm install -D @playwright/test` and install Electron support +- [x] 1.2 Create `playwright.config.ts` with Electron launch configuration +- [x] 1.3 Create `e2e/helpers/launch.ts` — shared helper for launching WaveTerm app + +## 2. Test Implementation + +- [x] 2.1 Write `e2e/search-clipboard.spec.ts` — search + copy-on-select clipboard test +- [x] 2.2 Verify test passes against a packaged build + +## 3. Integration + +- [x] 3.1 Add npm scripts: `npm run test:e2e` and `npm run test:e2e:build` +- [x] 3.2 Create `.github/workflows/playwright.yml` CI workflow diff --git a/package-lock.json b/package-lock.json index fdf8623be7..05925b3641 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ }, "devDependencies": { "@eslint/js": "^9.39", + "@playwright/test": "1.60.0", "@rollup/plugin-node-resolve": "^16.0.3", "@tailwindcss/vite": "^4.2.1", "@types/css-tree": "^2", @@ -6691,6 +6692,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -23614,6 +23631,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/package.json b/package.json index 367cf0d7e7..f80607177e 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,13 @@ "build:prod": "electron-vite build --mode production", "coverage": "vitest run --coverage", "test": "vitest", - "postinstall": "node ./postinstall.cjs" + "postinstall": "node ./postinstall.cjs", + "test:e2e": "playwright test", + "test:e2e:build": "task package -- --win --x64 --config.win.target=zip && playwright test" }, "devDependencies": { "@eslint/js": "^9.39", + "@playwright/test": "1.60.0", "@rollup/plugin-node-resolve": "^16.0.3", "@tailwindcss/vite": "^4.2.1", "@types/css-tree": "^2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..61ad1c5795 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 90000, + expect: { timeout: 15000 }, + retries: 0, + reporter: [["list"], ["html", { outputFolder: "playwright-report" }]], + outputDir: "test-results/playwright", +}); diff --git a/test-results/playwright/.last-run.json b/test-results/playwright/.last-run.json new file mode 100644 index 0000000000..cbcc1fbac1 --- /dev/null +++ b/test-results/playwright/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file