| name | playwright-testing |
| description | Write and debug Playwright E2E tests for Pipeline Studio. Use when creating E2E tests, debugging test failures, exploring selectors, or adding data-testid attributes to components. |
Playwright E2E Testing
Guidance for writing and debugging Playwright E2E tests for the Pipeline Studio application.
Project Context
Environment
- Frontend:
http://localhost:3000 (Vite dev server)
- Backend API:
http://localhost:8000
- Test Directory:
tests/e2e/
- Helpers:
tests/e2e/helpers.ts
Application Overview
Pipeline Studio is a React Flow-based ML pipeline editor with:
- Canvas: Drag-and-drop flow editor for building pipelines
- Component Library: Hierarchical folders of reusable ML components
- Context Panels: Side panels for pipeline/task details
- Node Connections: Input/output handles for data flow
Quick Probing with Playwright CLI
Interactive Browser Inspection
pnpm exec playwright codegen http://localhost:3000
pnpm exec playwright codegen http://localhost:3000 --output tests/e2e/recorded.spec.ts
Running Tests
pnpm run test:e2e
pnpm run test:e2e:ui
pnpm run test:e2e:headed
pnpm exec playwright test file.spec.ts
pnpm exec playwright test -g "test name"
pnpm exec playwright test --debug
pnpm exec playwright test --trace on
Locator Exploration
pnpm exec playwright open http://localhost:3000
Agent Debugging with playwright-cli
Use playwright-cli for interactive debugging and probing. It's designed for coding agents - token-efficient and provides element refs without loading full accessibility trees.
Install globally:
pnpm add -g @playwright/cli@latest
Core Debugging Workflow
playwright-cli open http://localhost:3000 --headed
playwright-cli snapshot
playwright-cli click e15
playwright-cli type "search query"
playwright-cli fill e23 "value"
playwright-cli press Enter
playwright-cli console
playwright-cli network
playwright-cli localstorage-list
playwright-cli cookie-list
playwright-cli close
Session Management
Use sessions to keep browser state between commands:
playwright-cli -s=tangle open http://localhost:3000 --headed
playwright-cli -s=tangle snapshot
playwright-cli -s=tangle click e5
playwright-cli -s=tangle console
playwright-cli list
playwright-cli -s=tangle close
playwright-cli close-all
Key Commands Reference
| Command | Description |
|---|
open [url] --headed | Open browser (visible) |
snapshot | Get page snapshot with element refs |
click <ref> | Click element by ref |
fill <ref> <text> | Fill input with text |
type <text> | Type into focused element |
press <key> | Press keyboard key |
hover <ref> | Hover over element |
select <ref> <value> | Select dropdown option |
check <ref> / uncheck <ref> | Toggle checkbox |
console [level] | View console messages |
network | View network requests |
screenshot [ref] | Take screenshot |
eval <code> [ref] | Evaluate JS on page/element |
Debugging Test Failures
When a test fails, reproduce the failure interactively:
playwright-cli -s=debug open http://localhost:3000 --headed
playwright-cli -s=debug snapshot
playwright-cli -s=debug click e12
playwright-cli -s=debug snapshot
playwright-cli -s=debug console --level=error
playwright-cli -s=debug network
Visual Dashboard
Monitor all running browser sessions:
playwright-cli show
Opens a dashboard showing:
- All active sessions with live screencast
- Click to zoom into any session
- Take over mouse/keyboard control
Trace Analysis (for test failures)
pnpm exec playwright test failing-test.spec.ts --trace on
pnpm exec playwright show-trace test-results/*/trace.zip
Test Patterns
Standard Test (Parallel)
import { expect, test } from "@playwright/test";
import { createNewPipeline } from "./helpers";
test.describe("Feature Name", () => {
test("should do something specific", async ({ page }) => {
await createNewPipeline(page);
await page.getByTestId("some-button").click();
await expect(page.getByTestId("result")).toBeVisible();
});
});
Serial Tests (Shared State)
Use for tests that share expensive setup (e.g., library loading):
import { expect, type Page, test } from "@playwright/test";
import { createNewPipeline } from "./helpers";
test.describe.configure({ mode: "serial" });
test.describe("Component Library", () => {
let page: Page;
const testResourceName = "TEST_RESOURCE";
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
await createNewPipeline(page);
});
test.afterAll(async () => {
await cleanupTestResource(page, testResourceName);
await page.close();
});
test("test one", async () => {
});
});
Test Cleanup
Always use afterAll for cleanup, not separate tests. If cleanup is a test and an earlier test fails, the system ends up in a corrupted state.
test("cleanup: remove test data", async () => {
await removeTestSecret(page);
});
test.afterAll(async () => {
await cleanupTestSecret(page, testSecretName);
await page.close();
});
Cleanup should fail loudly, not silently. Silent failures hide problems and make debugging harder.
async function cleanup(page: Page): Promise<void> {
try {
await removeItem(page);
} catch {
}
}
async function cleanupTestSecret(
page: Page,
secretName: string,
): Promise<void> {
const dialog = await openManageSecretsDialog(page);
const secretItem = dialog.locator(
`[data-testid="secret-item"][data-secret-name="${secretName}"]`,
);
await expect(
secretItem,
"Test secret should exist for cleanup",
).toBeVisible();
await secretItem.getByTestId("secret-remove-button").click();
await expect(secretItem, "Test secret should be removed").toBeHidden();
await closeDialog(page);
}
Helper Functions (from tests/e2e/helpers.ts)
Pipeline Setup
await createNewPipeline(page);
Canvas Locators & Interactions
const canvas = locateFlowCanvas(page);
const viewport = locateFlowViewport(page);
const pane = locateFlowPane(page);
const node = locateNodeByName(page, "Chicago Taxi Trips dataset");
await clickOnCanvas(page, 400, 300);
await panCanvas(page, deltaX, deltaY);
await zoomIn(page);
await zoomOut(page);
await fitToView(page);
Component Library
const folder = await openComponentLibFolder(page, "Quick start");
const component = locateComponentInFolder(folder, "Chicago Taxi Trips dataset");
const node = await dropComponentFromLibraryOnCanvas(
page,
"Quick start",
"Chicago Taxi Trips dataset",
{ targetPosition: { x: 400, y: 300 } },
);
await removeComponentFromCanvas(page, "Chicago Taxi Trips dataset");
Context Panels
const container = locateContextPanelContainer(page);
const panel = locateContextPanel(page, "pipeline-details");
await waitForContextPanel(page, "pipeline-details");
await waitForContextPanel(page, "task-overview");
Search Assertions
await assertSearchState(page, {
searchTerm: "CSV",
searchFilterCount: "2",
searchResultsCount: "5",
});
Debugging Tips
await page.pause();
const nodeBox = await node.boundingBox();
console.log("Node position:", nodeBox);
await page.screenshot({ path: "debug-screenshot.png" });
Key Selectors
For the complete selectors catalog, see selectors.md.
Most Common Selectors
| Selector | Description |
|---|
[data-testid="rf__wrapper"] | Main React Flow container |
[data-testid="rf__node-task_{name}"] | Task node by name |
[data-folder-name="{name}"] | Component library folder |
[data-testid="search-input"] | Search input field |
[data-testid="context-panel-container"] | Panel container |
[data-handleid="input_{name}"] | Input pin by name |
[data-handleid="output_{name}"] | Output pin by name |
Sticky Note (FlexNode) Selectors
| Selector | Description |
|---|
.react-flow__node-flex | Sticky note nodes |
[data-testid="sticky-note-sidebar-item"] | Sidebar drag item |
[data-testid="color-picker-popover"] | Color picker popover |
[data-testid="color-picker-trigger-{name}"] | Color picker trigger button |
[data-testid="color-preset-{hex}"] | Color preset (e.g., c8e6c9) |
[data-testid="color-preset-transparent"] | Transparent color preset |
[data-testid="stacking-controls"] | Z-index control buttons |
[data-testid="stacking-move-forward"] | Move forward button |
[data-testid="stacking-bring-to-front"] | Bring to front button |
[data-testid="selection-toolbar"] | Multi-select toolbar |
[data-testid="selection-duplicate-nodes"] | Duplicate button in toolbar |
Validation UI Selectors
| Selector | Description |
|---|
[data-testid="info-box-success"] | Success validation state (no issues) |
[data-testid="info-box-error"] | Error validation state |
[data-testid="info-box-warning"] | Warning validation state |
[data-testid="info-box-title"] | Validation title text (e.g., "2 errors detected") |
[data-testid="validation-group"] | Collapsible validation group |
[data-testid="validation-group-trigger"] | Button to expand/collapse group |
[data-testid="validation-issue"] | Individual validation issue button |
[data-issue-level="error"] | Data attribute on issue (error/warning) |
IO Node Selectors
| Selector | Description |
|---|
[data-testid="io-node-input-{name}"] | Input node by input name |
[data-testid="io-node-output-{name}"] | Output node by output name |
[data-testid="input-value-field"] | Input value textarea in editor |
Adding data-testid Attributes
When to Add
- Interactive elements (buttons, inputs, toggles)
- Dynamic containers (lists, grids, panels)
- Key UI landmarks (headers, navigation)
- Elements used in assertions
When NOT to Add
- Purely decorative elements
- Static text that won't be asserted
- Elements already identifiable by role/label
Naming Conventions
data-testid="submit-button"
data-testid="SubmitButton"
data-testid="btn1"
data-testid="input-handle-{inputName}"
data-testid="rf__node-task_{taskName}"
data-testid="search-input"
data-testid="search-results-header"
Adding to Components
<Button data-testid="save-pipeline-button">Save</Button>
<div data-testid={`input-handle-${input.name}`}>{input.value}</div>
<div
data-testid="component-item"
data-component-name={component.name}
>
{component.name}
</div>
Active Helper Opportunity Scanning
IMPORTANT: While writing or reviewing E2E tests, actively scan for helper opportunities. Don't just write tests—look for patterns that should be extracted.
Scan Triggers (Check After Every Test)
After writing each test, ask:
- Did I write similar code in another test? → Extract helper
- Is this action sequence reusable? → Extract helper
- Would another test author need to figure this out again? → Extract helper
Patterns That MUST Become Helpers
| Pattern | Example | Action |
|---|
| 3+ similar locator chains | page.getByTestId("dialog").getByRole("tab", { name: "X" }) repeated | Create locateDialogTab(page, tabName) |
| Multi-step workflows | Open modal → fill form → submit → verify | Create submitFormInModal(page, formData) |
| Wait + interact combos | await expect(x).toBeVisible(); await x.click(); | Create waitAndClick(page, locator) |
| Complex assertions | Multiple related expects | Create assertComponentState(page, expected) |
| Setup sequences | Navigate → wait → configure | Create setupFeatureTest(page, config) |
Active Scanning Checklist
While writing tests, flag these for extraction:
□ Repeated locator patterns (same getByTestId/getByRole chains)
□ Similar click sequences (open → interact → close)
□ Assertion groups that verify related state
□ Navigation + wait patterns
□ Form fill patterns
□ Drag-and-drop sequences
□ Modal/dialog interaction patterns
□ Table/list row interactions
When Writing a New Test File
- Before writing: Check
helpers.ts for existing helpers
- While writing: Note repeated patterns in comments:
// TODO: extract to helper
- After writing: Review TODOs and extract helpers that appear 2+ times
Helper Extraction Template
export async function helperName(
page: Page,
param1: string,
options: { option1?: boolean } = {},
): Promise<ReturnType> {
const element = page.getByTestId("element");
const box = await element.boundingBox();
if (!box) {
throw new Error("Unable to locate element bounding box");
}
return result;
}
Return values callers will need. When a helper opens a dialog or locates an element, return it so callers don't need to re-locate:
async function openDialog(page: Page): Promise<void> {
await page.getByTestId("open-button").click();
const dialog = page.getByTestId("my-dialog");
await expect(dialog).toBeVisible();
}
async function openDialog(page: Page) {
await page.getByTestId("open-button").click();
const dialog = page.getByTestId("my-dialog");
await expect(dialog).toBeVisible();
return dialog;
}
const dialog = await openDialog(page);
await dialog.getByTestId("submit").click();
Proposing New Helpers
When you identify a helper opportunity, propose it:
**Helper Opportunity Identified**
**Pattern**: [Describe the repeated pattern]
**Occurrences**: [Where it appears - file:line]
**Proposed helper**:
- Name: `doSomething(page, params)`
- Purpose: [What it abstracts]
- Returns: [What it returns]
Should I extract this to `helpers.ts`?
Best Practices
NEVER Do
await page.waitForTimeout(200);
const box = await element.boundingBox();
const x = box!.x;
const button = await page.getByTestId("submit");
await expect(button).toHaveClass("bg-blue-500");
if (await element.isVisible()) {
await element.click();
}
ALWAYS Do
await expect(element).toBeVisible();
await element.click();
const box = await element.boundingBox();
if (!box) {
throw new Error("Unable to locate element bounding box");
}
const button = page.getByTestId("submit");
await expect(button).toBeVisible();
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await expect(button).toHaveText("Submit");
await page.mouse.move(targetX, targetY, { steps: 10 });
Selector Priority
getByTestId() - Best for this app (stable, explicit)
getByRole() - Good for accessibility
getByText() - Good for static content
locator() with data attributes - When above don't work
- CSS selectors - Last resort
Error Context
await expect(
element,
"Component should appear after loading completes",
).toBeVisible();
Common Workflows
Adding a Component to Canvas
await createNewPipeline(page);
const node = await dropComponentFromLibraryOnCanvas(
page,
"Quick start",
"Chicago Taxi Trips dataset",
);
await expect(node).toBeVisible();
Connecting Two Nodes
const nodeA = await dropComponentFromLibraryOnCanvas(
page,
"folder",
"ComponentA",
);
const nodeB = await dropComponentFromLibraryOnCanvas(
page,
"folder",
"ComponentB",
{
targetPosition: { x: 500, y: 300 },
},
);
await fitToView(page);
const outputPin = nodeA.locator('[data-handleid="output_OutputName"]');
const inputPin = nodeB.locator('[data-handleid="input_InputName"]');
await outputPin.hover();
await page.mouse.down();
await inputPin.hover();
await page.mouse.up();
const edge = page.locator(
'[data-testid="rf__edge-ComponentA_OutputName-ComponentB_InputName"]',
);
await expect(edge).toBeVisible();
Testing Dialogs
await page.getByTestId("trigger-button").click();
const dialog = page.getByTestId("my-dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("tab", { name: "Settings" }).click();
await dialog.getByTestId("some-input").fill("value");
await dialog.locator('[data-slot="dialog-close"]').click();
await expect(dialog).toBeHidden();
Testing Search
await page.getByTestId("search-input").fill("query");
await expect(page.getByTestId("search-results-container")).toBeVisible();
await assertSearchState(page, {
searchTerm: "query",
searchResultsCount: "5",
});
await page.getByTestId("search-input").clear();
await expect(page.getByTestId("search-results-header")).toBeHidden();
Node Connection Testing
await expect(inputPin).toHaveAttribute("data-invalid", "true");
await outputPin.hover();
await page.mouse.down();
await inputPin.hover();
await page.mouse.up();
await expect(inputPin).toHaveAttribute("data-invalid", "false");
React Flow Gotchas
Escape Key Deselects Nodes
Pressing Escape in React Flow deselects the currently selected node. This causes context panels to switch from node editor to pipeline view.
When you WANT to deselect (e.g., to check pipeline-level state like validation):
await page.keyboard.press("Escape");
await waitForContextPanel(page, "pipeline-details");
await page.locator(".react-flow__pane").click({ position: { x: 100, y: 100 } });
When you DON'T want to deselect (e.g., closing a popover while keeping node selected):
await presetColor.click();
await page.keyboard.press("Escape");
await presetColor.click();
await stickyNote.click();
Highlight vs Select
Clicking validation issues or using fitNodeIntoView highlights nodes (orange border) but does NOT select them. The context panel won't change.
await validationIssue.click();
await expect(node).toBeInViewport();
Creating IO Nodes with Meta+Drag (Ghost Node)
Holding Meta (Cmd on Mac) while dragging from an input/output handle creates a new Input/Output node:
await page.keyboard.down("Meta");
await inputHandle.hover();
await page.mouse.down();
await page.mouse.move(targetX, targetY, { steps: 10 });
await page.mouse.up();
await page.keyboard.up("Meta");
const inputNode = page.getByTestId("io-node-input-{handleName}");
await expect(inputNode).toBeVisible();
Box Selection Needs Canvas Focus
For Shift+drag box selection to work, the canvas must have focus first:
await clickOnCanvas(page);
await page.keyboard.down("Shift");
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.keyboard.up("Shift");
Node Position via Transform
React Flow positions nodes using CSS transform, not absolute coordinates. Use style.transform to verify node movement:
const initialTransform = await node.evaluate((el) => el.style.transform);
const newTransform = await node.evaluate((el) => el.style.transform);
expect(newTransform).not.toBe(initialTransform);
const initialBox = await node.boundingBox();
const newBox = await node.boundingBox();
Fragile Selectors to Avoid
Inline Style Selectors
const greenPreset = popover.locator(
'[style*="background-color: rgb(200, 230, 201)"]',
);
const greenPreset = page.getByTestId("color-preset-c8e6c9");
Third-Party Library Internals
const popover = page.locator("[data-radix-popper-content-wrapper]");
const popover = page.getByTestId("color-picker-popover");
Validation Checklist
Always run before committing:
pnpm run validate
This catches:
- Import ordering issues
- TypeScript errors
- Unused dependencies
- Code formatting issues