| name | l-write-block-e2e |
| description | Generate comprehensive e2e tests for Lowdefy blocks using Playwright. Use when creating end-to-end tests for block functionality, testing block rendering, properties, events, and user interactions. |
| argument-hint | <package-name> <BlockName> |
E2E Test Generator for Lowdefy Blocks
Generate comprehensive end-to-end tests for Lowdefy block components using Playwright.
Usage
/l-write-block-e2e blocks-antd Button
/l-write-block-e2e blocks-basic Span
/l-write-block-e2e blocks-antd TextInput
Prerequisites
Before generating tests, ensure the block package has e2e infrastructure:
- e2e/playwright.config.js - Playwright configuration
- e2e/app/lowdefy.yaml - Test app configuration
- package.json - Has
e2e and e2e:ui scripts
- @lowdefy/block-dev-e2e - Dev dependency installed
If missing, set up the infrastructure first (see Package Setup section).
Instructions
Step 1: Analyze the Block
Read these files to understand the block:
-
Block component (src/blocks/{BlockName}/{BlockName}.js)
- Check ID pattern:
id={blockId} (display) vs id={\${blockId}_input`}` (input)
- Does it use Label wrapper? (input blocks do)
- What events does it support?
-
Schema (src/blocks/{BlockName}/schema.json)
- All available properties with types and defaults
- All available events
Step 2: Identify Block Type and Create Helper
CRITICAL: Always start with getBlock() which uses the framework wrapper #bl-{blockId}
The framework wrapper (#bl-{blockId}) is guaranteed to exist for ALL block types and should be your primary selector. getBlock(page, blockId) returns this wrapper element.
CRITICAL: Always use escapeId() when interpolating blockId into CSS selectors.
Block IDs can contain dots (e.g., form.field.name) which CSS interprets as class selectors. Always wrap with escapeId():
import { escapeId } from '@lowdefy/e2e-utils';
Two-step pattern for all blocks:
- Get the framework wrapper:
getBlock(page, blockId) → #bl-{blockId} (already escaped internally)
- Locate the Ant Design component inside:
.locator('.ant-xxx')
Display Blocks (Button, Alert, Badge, Progress, Result, etc.):
const getButton = (page, blockId) => getBlock(page, blockId).locator('.ant-btn');
const getAlert = (page, blockId) => getBlock(page, blockId).locator('.ant-alert');
const getBadge = (page, blockId) => getBlock(page, blockId).locator('.ant-badge');
const getProgress = (page, blockId) => getBlock(page, blockId).locator('.ant-progress');
Typography Display Blocks (Title, Paragraph):
const getTitle = (page, blockId) => getBlock(page, blockId).locator('h1, h2, h3, h4, h5');
const getParagraph = (page, blockId) => getBlock(page, blockId).locator('.ant-typography');
Input Blocks with Label (TextInput, NumberInput, etc.):
- Input element has a known ID pattern:
${blockId}_input
- Still use
getBlock() for the wrapper when checking labels, clear buttons, etc.
import { escapeId } from '@lowdefy/e2e-utils';
const getInput = (page, blockId) => page.locator(`#${escapeId(blockId)}_input`);
Select/Dropdown Blocks (Selector, MultipleSelector, etc.):
- Ant Design controls the
.ant-select wrapper (we can't add IDs to it)
- Scope with the input ID we DO control
const getSelector = (page, blockId) => page.locator(`.ant-select:has(#${escapeId(blockId)}_input)`);
const getOption = (page, blockId, index) => page.locator(`#${escapeId(blockId)}_${index}`);
Step 3: Create Test Fixtures
Create src/blocks/{BlockName}/tests/{BlockName}.e2e.yaml:
id: blockname
type: Box
events:
onInit:
- id: set_defaults
type: SetState
params:
blockname_with_value: Initial Value
blockname_clearable: Clear me
blocks:
- id: blockname_basic
type: BlockName
properties:
title: Basic Title
- id: blockname_with_value
type: BlockName
properties:
title: With Value
- id: blockname_disabled
type: BlockName
properties:
disabled: true
- id: blockname_small
type: BlockName
properties:
size: small
- id: blockname_onchange
type: BlockName
events:
onChange:
- id: set_onchange
type: SetState
params:
blockname_onchange_value:
_event: value
- id: onchange_display
type: Span
properties:
content:
_if:
test:
_ne:
- _state: blockname_onchange_value
- null
then:
_string.concat:
- 'Value: '
- _state: blockname_onchange_value
else: ''
- id: blockname_onblur
type: BlockName
events:
onBlur:
- id: set_onblur
type: SetState
params:
blockname_onblur_fired: true
- id: onblur_display
type: Span
properties:
content:
_if:
test:
_eq:
- _state: blockname_onblur_fired
- true
then: Blur fired
else: ''
- id: blockname_clearable
type: BlockName
properties:
allowClear: true
Block ID Naming Convention:
{blockname}_basic - Basic rendering
{blockname}_{property} - Property-specific (e.g., textinput_disabled)
{blockname}_on{event} - Event tests (e.g., textinput_onblur)
- Use lowercase, underscores
Step 4: Create Test Spec
Create src/blocks/{BlockName}/tests/{BlockName}.e2e.spec.js:
For Display Blocks (Button, Alert, Badge, etc.):
import { test, expect } from '@playwright/test';
import { getBlock, navigateToTestPage } from '@lowdefy/block-dev-e2e';
import { escapeId } from '@lowdefy/e2e-utils';
const getButton = (page, blockId) => getBlock(page, blockId).locator('.ant-btn');
test.describe('Button Block', () => {
test.beforeEach(async ({ page }) => {
await navigateToTestPage(page, 'button');
});
test('renders basic button', async ({ page }) => {
const block = getBlock(page, 'button_basic');
await expect(block).toBeVisible();
const button = getButton(page, 'button_basic');
await expect(button).toHaveText('Click Me');
});
test('renders primary type', async ({ page }) => {
const button = getButton(page, 'button_primary');
await expect(button).toHaveClass(/ant-btn-primary/);
});
test('renders disabled state', async ({ page }) => {
const button = getButton(page, 'button_disabled');
await expect(button).toBeDisabled();
});
test('onClick event fires', async ({ page }) => {
const button = getButton(page, 'button_onclick');
await button.click();
const display = getBlock(page, 'onclick_display');
await expect(display).toHaveText('Clicked!');
});
});
For Input Blocks (TextInput, NumberInput, etc.):
import { test, expect } from '@playwright/test';
import { getBlock, navigateToTestPage } from '@lowdefy/block-dev-e2e';
import { escapeId } from '@lowdefy/e2e-utils';
const getInput = (page, blockId) => page.locator(`#${escapeId(blockId)}_input`);
test.describe('TextInput Block', () => {
test.beforeEach(async ({ page }) => {
await navigateToTestPage(page, 'textinput');
});
test('renders with label', async ({ page }) => {
const block = getBlock(page, 'textinput_basic');
await expect(block).toBeVisible();
const label = block.locator('label');
await expect(label).toContainText('Basic Input');
});
test('renders with initial value', async ({ page }) => {
const input = getInput(page, 'textinput_with_value');
await expect(input).toHaveValue('Initial Value');
});
test('renders disabled state', async ({ page }) => {
const input = getInput(page, 'textinput_disabled');
await expect(input).toBeDisabled();
});
test('renders small size', async ({ page }) => {
const input = getInput(page, 'textinput_small');
await expect(input).toHaveClass(/ant-input-sm/);
});
test('onChange event fires when value changes', async ({ page }) => {
const input = getInput(page, 'textinput_onchange');
await input.fill('New Value');
const display = getBlock(page, 'onchange_display');
await expect(display).toHaveText('Value: New Value');
});
test('can clear value with clear button', async ({ page }) => {
const block = getBlock(page, 'textinput_clearable');
const input = getInput(page, 'textinput_clearable');
await expect(input).toHaveValue('Clear me');
await block.hover();
const clearBtn = block.locator('.ant-input-clear-icon');
await clearBtn.click();
await expect(input).toHaveValue('');
});
});
Step 5: Add Page Reference
Add the test page to e2e/app/lowdefy.yaml:
pages:
- _ref: ../../src/blocks/{BlockName}/tests/{BlockName}.e2e.yaml
Step 6: Run and Validate
pnpm e2e
pnpm e2e -- --grep "BlockName"
Fix any failures before committing.
Selector Strategy
Framework Wrapper ID Pattern
All Lowdefy blocks are wrapped by BlockLayout which renders id="bl-{blockId}". This wrapper ID is framework-guaranteed and always exists, making it the most reliable selector for targeting blocks.
getBlock(page, blockId) uses #bl-{blockId} (framework wrapper, escapes internally)
- Input elements use
#${escapeId(blockId)}_input (component-rendered, must escape manually)
- Options use
#${escapeId(blockId)}_0, #${escapeId(blockId)}_1, etc. (must escape manually)
Selector Priority (Most to Least Preferred)
| Priority | Selector Type | When to Use | Example |
|---|
| 1 | Framework wrapper | Block container | getBlock() → #bl-{blockId} |
| 2 | Input ID | Form inputs | #${escapeId(blockId)}_input |
| 3 | Role-based | Buttons with accessible names | getByRole('button', { name: 'Copy' }) |
| 4 | Class + ID | Ant Design internals | .ant-select:has(#${escapeId(blockId)}_input) |
| 5 | Class only | Last resort - style assertions | .ant-input-sm |
Role-Based Selectors (Preferred for Ant Design Buttons)
Use Playwright's role-based selectors for Ant Design action buttons:
const copyBtn = block.getByRole('button', { name: 'Copy' });
const editBtn = block.getByRole('button', { name: 'Edit' }).first();
Why role-based is better:
- Uses semantic meaning (what the button does) not implementation (class names)
- Less fragile than class selectors like
.ant-typography-copy
- Ant Design won't change accessible names without it being a breaking change
About Class Selectors
We use .ant-select:has(#${escapeId(blockId)}_input) for Selector blocks because:
- The
.ant-select wrapper is created internally by Ant Design
- We cannot add
data-testid to it (we don't control that element)
- We scope it with the ID we DO control to ensure uniqueness
- This is standard practice for testing Ant Design components
When class selectors are still needed:
- Style assertions:
await expect(input).toHaveClass(/ant-input-sm/);
- Elements without accessible names (wrapper divs, icons)
- The
.ant-typography-edit-content wrapper for Typography edit mode
Ant Design Class Patterns
Size Classes
| Component | Small | Large |
|---|
| Input/TextArea | ant-input-sm | ant-input-lg |
| Button | ant-btn-sm | ant-btn-lg |
| Select | ant-select-sm | ant-select-lg |
| NumberInput wrapper | ant-input-number-sm | ant-input-number-lg |
| DatePicker | ant-picker-small | ant-picker-large |
Style Classes
| Style | Class Pattern |
|---|
| Borderless | ant-input-borderless, ant-select-borderless |
| Disabled Select | ant-select-disabled |
| Loading Button | ant-btn-loading |
| Primary Button | ant-btn-primary |
| Danger Button | ant-btn-dangerous |
Typography Classes
| Type | Class |
|---|
| Secondary | ant-typography-secondary |
| Warning | ant-typography-warning |
| Danger | ant-typography-danger |
| Success | ant-typography-success |
| Disabled | ant-typography-disabled |
Common Test Patterns
Testing Clear Button (requires hover)
test('can clear value', async ({ page }) => {
const block = getBlock(page, 'blockname_clearable');
const input = getInput(page, 'blockname_clearable');
await expect(input).toHaveValue('Clear me');
await block.hover();
const clearBtn = block.locator('.ant-input-clear-icon');
await clearBtn.click();
await expect(input).toHaveValue('');
});
Testing NumberInput Controls
test('can increment with controls', async ({ page }) => {
const block = getBlock(page, 'numberinput_controls');
const input = getInput(page, 'numberinput_controls');
await expect(input).toHaveValue('10');
await block.hover();
const upHandler = block.locator('.ant-input-number-handler-up');
await upHandler.click();
await expect(input).toHaveValue('11');
});
Testing Select Dropdown
test('can select option', async ({ page }) => {
const selector = getSelector(page, 'selector_basic');
await selector.click();
const option = getOption(page, 'selector_basic', 1);
await option.click();
const selected = selector.locator('.ant-select-selection-item');
await expect(selected).toHaveText('Option 2');
});
Testing Password Visibility Toggle
test('can toggle password visibility', async ({ page }) => {
const block = getBlock(page, 'passwordinput_visibility');
const input = getInput(page, 'passwordinput_visibility');
await expect(input).toHaveAttribute('type', 'password');
const toggle = block.locator('.ant-input-password-icon');
await toggle.click();
await expect(input).toHaveAttribute('type', 'text');
});
Known Patterns
Typography Editable (TitleInput, ParagraphInput)
The editable typography components replace the element with an edit wrapper when clicking the edit button:
test('can edit title and onChange fires', async ({ page }) => {
const block = getBlock(page, 'titleinput_onchange');
const editBtn = block.getByRole('button', { name: 'Edit' }).first();
await editBtn.click();
const textarea = page.locator('.ant-typography-edit-content textarea');
await expect(textarea).toBeVisible();
await textarea.fill('New Title Value');
await textarea.press('Enter');
const display = getBlock(page, 'onchange_display');
await expect(display).toHaveText('Value: New Title Value');
});
test('onCopy event fires when copy button is clicked', async ({ page }) => {
const block = getBlock(page, 'titleinput_oncopy');
const copyBtn = block.getByRole('button', { name: 'Copy' });
await copyBtn.click();
const display = getBlock(page, 'oncopy_display');
await expect(display).toHaveText('Copy fired');
});
Key insights:
- When Typography enters edit mode, the heading/paragraph is replaced with a
div.ant-typography-edit-content containing a textarea
- Use
page.locator('.ant-typography-edit-content textarea') instead of block.locator('textarea')
- For
onCopy to fire, copyable must be an object (e.g., copyable: { text: 'Copy text' }), not just true
TextArea maxLength
TextArea enforces maxLength at component level, not via HTML attribute. Test by filling more characters and checking result:
await textarea.fill('a'.repeat(150));
const value = await textarea.inputValue();
expect(value.length).toBeLessThanOrEqual(100);
DatePicker Blocks (DateSelector, DateTimeSelector, etc.)
Date picker blocks use Ant Design's DatePicker which has specific patterns:
const getPicker = (page, blockId) => page.locator(`#bl-${escapeId(blockId)} .ant-picker`);
const getInput = (page, blockId) => page.locator(`#${escapeId(blockId)}_input`);
test('can select a date', async ({ page }) => {
const input = getInput(page, 'ds_basic');
await input.click();
const dropdown = page.locator('.ant-picker-dropdown:visible');
await expect(dropdown).toBeVisible();
const dateCell = page.locator('.ant-picker-cell-in-view').first();
await dateCell.click();
await expect(input).toHaveValue(/\d{4}-\d{2}-\d{2}/);
});
test('can clear value', async ({ page }) => {
const picker = getPicker(page, 'ds_clearable');
const input = getInput(page, 'ds_clearable');
await input.click();
await page.locator('.ant-picker-cell-in-view').first().click();
await picker.hover();
await picker.locator('.ant-picker-clear').click();
await expect(input).toHaveValue('');
});
Key insights:
- DatePicker size classes are
ant-picker-small/large (not sm/lg)
- Use
.ant-picker-cell-in-view for reliable date cell selection
- RangePicker: start input has ID, end input needs
.ant-picker-input:last-child input
Package Setup (First-Time Only)
1. Create e2e/playwright.config.js
import path from 'path';
import { fileURLToPath } from 'url';
import { createPlaywrightConfig } from '@lowdefy/block-dev-e2e';
const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
export default createPlaywrightConfig({
packageDir,
port: 3002,
});
2. Create e2e/app/lowdefy.yaml
lowdefy: local
name: {package-name} E2E Tests
pages:
- _ref: ../../src/blocks/{BlockName}/tests/{BlockName}.e2e.yaml
3. Update package.json
"devDependencies": {
"@lowdefy/block-dev-e2e": "4.5.2",
"@playwright/test": "1.50.1"
}
"scripts": {
"e2e": "playwright test --config e2e/playwright.config.js",
"e2e:ui": "playwright test --config e2e/playwright.config.js --ui"
}
Shared Utilities
The @lowdefy/block-dev-e2e package provides:
createPlaywrightConfig({ packageDir, port }) - Creates Playwright config
getBlock(page, blockId) - Gets element by #bl-{blockId} (framework wrapper ID)
navigateToTestPage(page, pageId) - Navigates to test page
Port Assignments
| Package | Port |
|---|
| blocks-basic | 3001 |
| blocks-antd | 3002 |
| blocks-aggrid | 3003 |
| blocks-markdown | 3004 |
Commit Convention
test({package}): Add {BlockName} e2e tests
Add Playwright e2e tests for {BlockName} covering:
- {list key test scenarios}
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>