con un clic
l-write-block-e2e
// 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.
// 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.
Stage and commit changes with conventional commit format. Smart analysis groups changes, proposes splits, and writes structured commit descriptions. Use when committing code changes.
Generate a changeset file for the current branch. Analyzes commits, determines bump types, and writes a user-facing changelog entry. Use when preparing version bumps.
Create a GitHub issue for the lowdefy repo. Auto-detects bug vs feature, drafts with appropriate template, and creates with labels. Use when filing bugs, feature requests, or enhancements.
Update a single dependency by reading its full changelog and assessing impact. Safe updates are applied directly, major updates are flagged for design review. Use when updating packages.
Create a draft pull request targeting develop. Auto-generates PR body from design files, GitHub issues, and/or commit history. Use when opening a PR.
| 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> |
Generate comprehensive end-to-end tests for Lowdefy block components using Playwright.
/l-write-block-e2e blocks-antd Button
/l-write-block-e2e blocks-basic Span
/l-write-block-e2e blocks-antd TextInput
Before generating tests, ensure the block package has e2e infrastructure:
e2e and e2e:ui scriptsIf missing, set up the infrastructure first (see Package Setup section).
Read these files to understand the block:
Block component (src/blocks/{BlockName}/{BlockName}.js)
id={blockId} (display) vs id={\${blockId}_input`}` (input)Schema (src/blocks/{BlockName}/schema.json)
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:
getBlock(page, blockId) → #bl-{blockId} (already escaped internally).locator('.ant-xxx')Display Blocks (Button, Alert, Badge, Progress, Result, etc.):
// Pattern: getBlock() returns wrapper, then locate Ant component inside
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.):
${blockId}_inputgetBlock() for the wrapper when checking labels, clear buttons, etc.import { escapeId } from '@lowdefy/e2e-utils';
// For the input element specifically (has its own ID)
const getInput = (page, blockId) => page.locator(`#${escapeId(blockId)}_input`);
// For wrapper-level operations (hover for clear button, check label, etc.)
// Use getBlock(page, blockId) directly
Select/Dropdown Blocks (Selector, MultipleSelector, etc.):
.ant-select wrapper (we can't add IDs to it)const getSelector = (page, blockId) => page.locator(`.ant-select:has(#${escapeId(blockId)}_input)`);
const getOption = (page, blockId, index) => page.locator(`#${escapeId(blockId)}_${index}`);
Create src/blocks/{BlockName}/tests/{BlockName}.e2e.yaml:
# Copyright 2020-2026 Lowdefy, Inc
# ... license header ...
id: blockname # Page ID - lowercase, used in URL
type: Box
events:
onInit:
- id: set_defaults
type: SetState
params:
blockname_with_value: Initial Value
blockname_clearable: Clear me
blocks:
# ============================================
# BASIC RENDERING
# ============================================
- id: blockname_basic
type: BlockName
properties:
title: Basic Title
- id: blockname_with_value
type: BlockName
properties:
title: With Value
# ============================================
# PROPERTY TESTS
# ============================================
- id: blockname_disabled
type: BlockName
properties:
disabled: true
- id: blockname_small
type: BlockName
properties:
size: small
# ============================================
# EVENT TESTS
# ============================================
# For value-based events (onChange with value)
- 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: ''
# For boolean events (onBlur, onFocus, etc.)
- 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: ''
# ============================================
# INTERACTION TESTS
# ============================================
- 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)Create src/blocks/{BlockName}/tests/{BlockName}.e2e.spec.js:
For Display Blocks (Button, Alert, Badge, etc.):
/*
Copyright 2020-2026 Lowdefy, Inc
Licensed under the Apache License, Version 2.0 (the "License");
...
*/
import { test, expect } from '@playwright/test';
import { getBlock, navigateToTestPage } from '@lowdefy/block-dev-e2e';
import { escapeId } from '@lowdefy/e2e-utils';
// Display block: use framework wrapper, then locate Ant component inside
const getButton = (page, blockId) => getBlock(page, blockId).locator('.ant-btn');
test.describe('Button Block', () => {
test.beforeEach(async ({ page }) => {
await navigateToTestPage(page, 'button'); // matches yaml id
});
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';
// Input block: input element has specific ID pattern
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('');
});
});
Add the test page to e2e/app/lowdefy.yaml:
pages:
- _ref: ../../src/blocks/{BlockName}/tests/{BlockName}.e2e.yaml
pnpm e2e # Run all tests
pnpm e2e -- --grep "BlockName" # Run specific block tests
Fix any failures before committing.
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)#${escapeId(blockId)}_input (component-rendered, must escape manually)#${escapeId(blockId)}_0, #${escapeId(blockId)}_1, etc. (must escape manually)| 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 |
Use Playwright's role-based selectors for Ant Design action buttons:
// Copy button - single element, works directly
const copyBtn = block.getByRole('button', { name: 'Copy' });
// Edit button - Ant Design has nested elements, use .first()
const editBtn = block.getByRole('button', { name: 'Edit' }).first();
Why role-based is better:
.ant-typography-copyWe use .ant-select:has(#${escapeId(blockId)}_input) for Selector blocks because:
.ant-select wrapper is created internally by Ant Designdata-testid to it (we don't control that element)When class selectors are still needed:
await expect(input).toHaveClass(/ant-input-sm/);.ant-typography-edit-content wrapper for Typography edit mode| 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 | 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 |
| Type | Class |
|---|---|
| Secondary | ant-typography-secondary |
| Warning | ant-typography-warning |
| Danger | ant-typography-danger |
| Success | ant-typography-success |
| Disabled | ant-typography-disabled |
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('');
});
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');
});
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');
});
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');
});
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');
// Click the edit button - use role-based selector with .first()
// (Ant Design has nested button elements)
const editBtn = block.getByRole('button', { name: 'Edit' }).first();
await editBtn.click();
// After clicking, the element is replaced with an editable wrapper
// Use page.locator to find the textarea (not block.locator)
const textarea = page.locator('.ant-typography-edit-content textarea');
await expect(textarea).toBeVisible();
await textarea.fill('New Title Value');
// Press Enter to confirm
await textarea.press('Enter');
// Verify the onChange event fired
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');
// Copy button - role-based selector works directly (single element)
const copyBtn = block.getByRole('button', { name: 'Copy' });
await copyBtn.click();
const display = getBlock(page, 'oncopy_display');
await expect(display).toHaveText('Copy fired');
});
Key insights:
div.ant-typography-edit-content containing a textareapage.locator('.ant-typography-edit-content textarea') instead of block.locator('textarea')onCopy to fire, copyable must be an object (e.g., copyable: { text: 'Copy text' }), not just trueTextArea 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);
Date picker blocks use Ant Design's DatePicker which has specific patterns:
// Helper to get the picker wrapper (use framework wrapper ID)
const getPicker = (page, blockId) => page.locator(`#bl-${escapeId(blockId)} .ant-picker`);
// Helper to get the input
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();
// Wait for dropdown
const dropdown = page.locator('.ant-picker-dropdown:visible');
await expect(dropdown).toBeVisible();
// Use .ant-picker-cell-in-view for reliable date cell targeting
// (more reliable than .ant-picker-cell-today which may not be visible)
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');
// First select a date
await input.click();
await page.locator('.ant-picker-cell-in-view').first().click();
// Hover to reveal clear button
await picker.hover();
await picker.locator('.ant-picker-clear').click();
await expect(input).toHaveValue('');
});
Key insights:
ant-picker-small/large (not sm/lg).ant-picker-cell-in-view for reliable date cell selection.ant-picker-input:last-child input/*
Copyright 2020-2026 Lowdefy, Inc
... license header ...
*/
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, // See port assignments below
});
# Copyright 2020-2026 Lowdefy, Inc
# ... license header ...
lowdefy: local
name: {package-name} E2E Tests
pages:
- _ref: ../../src/blocks/{BlockName}/tests/{BlockName}.e2e.yaml
"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"
}
The @lowdefy/block-dev-e2e package provides:
createPlaywrightConfig({ packageDir, port }) - Creates Playwright configgetBlock(page, blockId) - Gets element by #bl-{blockId} (framework wrapper ID)navigateToTestPage(page, pageId) - Navigates to test page| Package | Port |
|---|---|
| blocks-basic | 3001 |
| blocks-antd | 3002 |
| blocks-aggrid | 3003 |
| blocks-markdown | 3004 |
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>