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
34 changes: 34 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions e2e/helpers/launch.ts
Original file line number Diff line number Diff line change
@@ -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<ElectronApplication> {
const app = await electron.launch({
executablePath: APP_PATH,
args: [],
env: {
...process.env,
WAVETERM_NOCONFIRMQUIT: "1",
},
});
return app;
}

export async function getMainWindow(app: ElectronApplication): Promise<Page> {
return await app.firstWindow();
}
47 changes: 47 additions & 0 deletions e2e/search-clipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Page> {
return await app.firstWindow();
}

async function setClipboard(app: ElectronApplication, text: string): Promise<void> {
await app.evaluate(({ clipboard }, txt) => clipboard.writeText(txt), text);
}

async function readClipboard(app: ElectronApplication): Promise<string> {
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");
});
});
10 changes: 9 additions & 1 deletion frontend/app/element/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import "./search.scss";

type SearchProps = SearchAtoms & {
anchorRef?: React.RefObject<HTMLElement>;
searchInputRef?: React.RefObject<HTMLInputElement>;
offsetX?: number;
offsetY?: number;
onSearch?: (search: string) => void;
Expand All @@ -34,6 +35,8 @@ const SearchComponent = ({
onNext,
onPrev,
}: SearchProps) => {
const localInputRef = useRef<HTMLInputElement>(null);
const inputRef = providedInputRef || localInputRef;
const [isOpen, setIsOpen] = useAtom<boolean>(isOpenAtom);
const [search, setSearch] = useAtom<string>(searchAtom);
const [index, setIndex] = useAtom<number>(indexAtom);
Expand Down Expand Up @@ -198,6 +201,7 @@ export const Search = memo(SearchComponent) as typeof SearchComponent;

type SearchOptions = {
anchorRef?: React.RefObject<HTMLElement>;
searchInputRef?: React.RefObject<HTMLInputElement>;
viewModel?: ViewModel;
regex?: boolean;
caseSensitive?: boolean;
Expand All @@ -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 = (
Expand Down
25 changes: 25 additions & 0 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/view/term/term.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,10 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
const isBasicTerm = termMode != "vdom" && blockData?.meta?.controller != "cmd"; // needs to match isBasicTerm

// search
const searchInputRef = React.useRef<HTMLInputElement>(null);
const searchProps = useSearch({
anchorRef: viewRef,
searchInputRef: searchInputRef,
viewModel: model,
caseSensitive: false,
wholeWord: false,
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/add-playwright-e2e/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-30
41 changes: 41 additions & 0 deletions openspec/changes/add-playwright-e2e/design.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions openspec/changes/add-playwright-e2e/proposal.md
Original file line number Diff line number Diff line change
@@ -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

- `<existing-name>`: 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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions openspec/changes/add-playwright-e2e/tasks.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading