com um clique
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.
| 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>
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.