name: e2e-playwright-mcp description: Domain knowledge for Playwright MCP-assisted E2E test development in Lichtblick. Covers test architecture, fixture reference, selector strategy, page objects, MCP usage, and source instrumentation patterns.
E2E Testing with Playwright MCP
This skill covers writing E2E tests for Lichtblick using Playwright, with AI-assisted exploration
via the Playwright MCP server configured in .vscode/mcp.json.
Follow the test-conventions skill for GWT pattern and core quality rules.
Test Architecture
Two Platforms
| Platform | Runner | Base URL / Entry | Config |
|---|---|---|---|
| Web | Chromium | http://localhost:8080 (via yarn web:serve) |
e2e/tests/web/playwright.config.ts |
| Desktop | Electron | Custom electronApp fixture |
e2e/tests/desktop/playwright.config.ts |
Primary focus: desktop. Write web tests only when the behavior is web-specific (e.g., URL-based data loading, multi-tab BroadcastChannel sync, timestamp URL parameters).
File Placement
e2e/tests/
desktop/
open-files/
sidebar/
layout/
extension/
panel/
player/
settings/
topics/
variables/
menu/
remote-data/
web/
open-files/
Group new tests under the most relevant subdirectory. Create a new subdirectory if no existing one fits.
Filename Pattern
{feature-name}.{platform}.spec.ts
Examples: loop-playback.desktop.spec.ts, sync-playback.web.spec.ts
Test Tagging
Every test must carry exactly one tag. This controls which CI pipeline executes it.
| Tag | CI trigger | Criteria |
|---|---|---|
@smoke |
Every PR push | Primary happy-path a user encounters immediately: opening files, basic player controls (play/pause), primary layout actions (create, open), and main menu navigation. Must be fast (<30 s). |
@regression |
Nightly (02:00 UTC) + push to develop / main |
Edge cases, secondary interactions (keyboard shortcuts, speed variants), extended scenarios (timestamp switching, topic filtering), or slower workflows (extensions, remote data, WebSocket). |
Syntax
// Single test
test("should open a file", { tag: "@smoke" }, async ({ mainWindow }) => { ... });
// All tests in a describe block
test.describe("extension lifecycle", { tag: "@regression" }, () => { ... });
Fixtures Reference
Desktop (e2e/fixtures/electron.ts)
import { test, expect } from "../../../fixtures/electron";
// Provides: { mainWindow } — a Playwright Page for the Electron renderer
// Also: { electronApp } — the ElectronApplication instance
// Also: { electronArgs } — CLI arguments passed to Electron (e.g., --source=<path>)
// Also: { preInstalledExtensions } — array of extension filenames pre-installed in temp home dir
Web (@playwright/test)
import { test, expect } from "@playwright/test";
// Standard Playwright test with Chromium
Helper Fixtures
import { loadFiles } from "../../../fixtures/load-files";
// loadFiles({ mainWindow, filenames: "example.mcap" })
// loadFiles({ mainWindow, filenames: ["a.mcap", "b.mcap"] })
// Uses the hidden file input element ([data-puppeteer-file-upload])
import { changeToEpochFormat } from "../../../fixtures/change-to-epoch-format";
// changeToEpochFormat(mainWindow)
// Converts timestamp display to epoch seconds for stable numeric assertions
import { launchWebsocket } from "../../../fixtures/launch-websocket";
// launchWebsocket() — starts a local FoxgloveServer WebSocket server on port 8765
// Returns { close } for cleanup
import { loadFromFilePicker } from "../../../fixtures/load-from-file-picker";
// loadFromFilePicker(mainWindow, "filename.json")
// Mocks window.showOpenFilePicker for web file picker tests
import { TEST_MCAP_URL } from "../../../fixtures/urls";
// URL to a hosted NuScenes MCAP file for URL-based loading tests
Selector Strategy
Priority Order
data-testid— most stable, preferred for interactive controlsmainWindow.getByTestId("play-button") mainWindow.getByTestId("loop-playback-button") mainWindow.getByTestId("DataSourceDialog")getByRole— semantic and accessible, preferred for menus, dialogs, buttonsmainWindow.getByRole("menuitem", { name: "Save changes" }) mainWindow.getByRole("button", { name: "Close" }) mainWindow.getByRole("listitem").filter({ hasText: "Default" })getByText/getByPlaceholder— for labels that are not interactive elementsmainWindow.getByText("User Scripts panel") mainWindow.getByPlaceholder("Search Extensions...")locator()with CSS — last resort, only for elements without accessible roles or test IDsmainWindow.locator('[data-puppeteer-file-upload]') mainWindow.locator('input[value="some-value"]')
Menu Item Selectors (Mandatory Convention)
ALWAYS use getByRole("menuitem", { name: "..." }) for menu items. NEVER use getByTestId(...) for menu items, even though data-testid attributes exist on <MenuItem> elements in the source code.
// CORRECT — matches codebase convention
await page.getByRole("menuitem", { name: "Delete" }).click();
await page.getByRole("menuitem", { name: "Rename" }).click();
await page.getByRole("menuitem", { name: "Revert" }).click();
// WRONG — do NOT use even though data-testid="delete-layout" exists in source
await page.getByTestId("delete-layout").click();
Toggle Button State Assertions
await expect(button).toHaveAttribute("aria-pressed", "true")
Avoid
- MUI class names (e.g.,
.MuiButton-root) — unstable across builds - CSS selectors with internal component structure
- XPath selectors
nth(0)without context — be specific about which element you mean
Hover-to-Reveal Elements (CSS visibility: hidden)
Some interactive elements have visibility: hidden and only appear on hover. You must hover the parent element before clicking the hidden child, or Playwright will time out.
How to detect: Read the component's .style.ts file and look for visibility: "hidden" rules tied to :hover pseudo-selectors.
Example — layout row action buttons (LayoutRow.style.ts):
The layout-actions button is hidden when a layout has no unsaved changes:
".MuiListItemSecondaryAction-root": {
visibility: !hasModifications && !deletedOnServer && "hidden",
},
"&:hover .MuiListItemSecondaryAction-root": {
visibility: "visible",
},
Correct pattern — hover before clicking:
const layoutRow = page.getByRole("listitem").filter({ hasText: name });
await layoutRow.hover();
await layoutRow.getByTestId("layout-actions").click();
await page.getByRole("menuitem", { name: "Delete" }).click();
Exception: When a layout HAS unsaved changes, the icon is always visible (it becomes the unsaved-changes-icon). The revertLayout() POM method skips hover because it only applies to modified layouts.
General rule: Before clicking any element that might be hidden, check the component's style file for visibility rules. If the element is conditionally hidden, add a hover() call on its container.
Page Object Models (POMs)
Reusable UI abstractions live in e2e/page-objects/. Import from the barrel file:
import { DataSourceDialog, Sidebar, PlayerControls, LayoutManager, ExtensionManager, AppMenu, Panels } from "../../../page-objects";
Available POMs
All POMs live in e2e/page-objects/ and are re-exported from e2e/page-objects/index.ts. Before writing a test, read the barrel file and then read the specific POM file(s) you plan to use to discover available methods and their signatures.
| POM | Purpose |
|---|---|
DataSourceDialog |
Data source dialog interactions |
Sidebar |
Left/right sidebar tabs |
PlayerControls |
Playback controls |
LayoutManager |
Layout CRUD (create, rename, delete, import, revert) |
ExtensionManager |
Extension install/uninstall workflows |
AppMenu |
App menu navigation |
Panels |
Panel add/configure operations |
When to Use POMs vs Direct Selectors
- Use POMs for common, repeated interactions (dismissing dialogs, navigating sidebar, player controls)
- Use direct selectors for one-off, test-specific elements that don't appear across multiple tests
Common Patterns
Dismiss the startup dialog
const dialog = new DataSourceDialog(mainWindow);
await dialog.close();
Open the Layouts left sidebar
const sidebar = new Sidebar(mainWindow);
await sidebar.openLayoutsTab();
Load a file and start playback
await loadFiles({ mainWindow, filenames: "example.mcap" });
await changeToEpochFormat(mainWindow);
const player = new PlayerControls(mainWindow);
await player.play();
Open a file via CLI arguments
import path from "path";
const filePath = path.resolve(process.cwd(), "e2e/fixtures/assets", "example.mcap");
test.use({ electronArgs: [`--source=${filePath}`] });
Seek to a position via the slider
const slider = mainWindow.getByRole("slider");
await slider.focus();
await slider.evaluate((el, value) => {
(el as HTMLInputElement).value = String(value);
el.dispatchEvent(new Event("change", { bubbles: true }));
}, targetValue);
Context menus (right-click)
await item.getByRole("button", { name: "Layout name" }).click({ button: "right" });
await expect(mainWindow.getByRole("menuitem", { name: "Rename" })).toBeVisible();
await mainWindow.keyboard.press("Escape"); // dismiss after assertion
Layout action menu (hover-to-reveal)
For layout rows where the layout has no unsaved changes, the actions button is hidden until hover:
// Full pattern: hover → actions → menu item → optional confirmation
const layoutRow = page.getByRole("listitem").filter({ hasText: "My Layout" });
await layoutRow.hover();
await layoutRow.getByTestId("layout-actions").click();
await page.getByRole("menuitem", { name: "Delete" }).click();
// Destructive actions show a confirmation dialog
await page.getByRole("button", { name: "Delete" }).click();
Prefer the POM when available:
const layout = new LayoutManager(mainWindow);
await layout.deleteLayout("My Layout"); // handles hover + menu + confirm
await layout.renameLayout("Old", "New"); // handles hover + menu + fill + Enter
Confirmation dialogs (destructive actions)
Destructive actions (Delete, Revert with confirm) show a confirmation dialog:
- Heading: e.g.,
Delete "layoutName"? - Message:
This action cannot be undone. - Buttons:
getByRole("button", { name: "Delete" })andgetByRole("button", { name: "Cancel" })
Always use getByRole("button", { name: "..." }) for confirmation buttons, not getByTestId.
Multi-tab BroadcastChannel tests (web only)
const context = await browser.newContext();
const page1 = await context.newPage();
const page2 = await context.newPage();
await page1.goto("http://localhost:8080");
await page2.goto("http://localhost:8080");
// ...
await context.close();
Pre-install extensions for a test
test.use({
preInstalledExtensions: ["lichtblick.suite-extension-turtlesim-0.0.1"],
});
Instrumentation: Adding data-testid to Source Components
When no stable selector exists for an interactive element, add a data-testid to the source component. You have edit tool access for this purpose.
Rules
- Add
data-testidonly when: no existingdata-testid,getByRoleis ambiguous, and MUI class names are the only alternative. - Place the attribute on the outermost interactive element (the actual
<button>,<input>, etc.). - For toggle buttons, also add
aria-pressed={booleanState}for state assertions. - Do not add
data-testidto wrapper<div>elements — target the element that receives focus/click. - Use kebab-case naming:
data-testid="loop-playback-button", notdata-testid="LoopPlaybackButton".
Example — IconButton with toggle state
<HoverableIconButton
data-testid="loop-playback-button"
aria-pressed={repeat}
onClick={toggleRepeat}
// ... other props
/>
Example — Regular Button with on/off state
<Button
data-testid="sync-toggle-button"
aria-pressed={syncInstances}
onClick={toggleSync}
>
Sync
</Button>
Playwright MCP Usage
The MCP server is configured in .vscode/mcp.json with --isolated, --browser chrome, --codegen typescript, and --caps testing.
Workflow
MCP browser exploration is for WEB tests only. Do not open a browser or use any MCP browser tools when writing desktop (Electron) tests. Desktop tests run headless via an Electron fixture — the MCP Chromium browser cannot drive Electron, selectors may differ, and using it adds unnecessary overhead. For desktop tests, discover selectors by reading source components and existing test files.
- Start the dev server —
yarn web:serve(for web tests) - Open Copilot in Agent mode
- Use MCP to explore the UI:
- Navigate to the target URL or state
- Take accessibility snapshots to discover selectors
- Interact with elements to verify behavior
- Use
--caps testingassertions to verify visibility
- Extract stable selectors from the snapshot (prefer
data-testidand role-based) - Write the test using verified selectors
- Run the test to confirm it passes
Key MCP Limitations
- MCP controls the web app (Chrome) — it cannot drive the Electron desktop app
- Do not use MCP for desktop tests — desktop test selectors must be discovered by reading existing desktop test files, Page Object Models (
e2e/page-objects/), and source components. The Chromium browser controlled by MCP is not equivalent to the Electron runtime used by desktop tests. - MCP
browser_run_codedoes not have access to Node.js globals (process,require) — use absolute paths or avoid file operations in code snippets - Screenshots and snapshots are saved under
.playwright-mcp/(gitignored)
Test Commands
yarn test:e2e:web # All web E2E tests
yarn test:e2e:web:debug # Debug mode (headed)
yarn test:e2e:desktop # All desktop E2E tests
yarn test:e2e:desktop:debug <filter> # Debug a single test by filename fragment
yarn test:e2e:desktop:ci # Headless CI mode