packages/utils/e2e-utils/README.md
Playwright testing utilities for Lowdefy applications with a polymorphic block API.
pnpm add -D @lowdefy/e2e-utils @playwright/test
npx playwright install chromium
Initialize e2e testing in your Lowdefy app:
npx lowdefy-e2e init
This creates:
e2e/playwright.config.js - Pre-configured Playwright confige2e/example.spec.js - Starter test to copy fromRun tests:
pnpm e2e # Run tests
pnpm e2e:ui # Run with Playwright UI
Import test from our fixtures - it includes the ldf helper automatically:
import { test, expect } from '@lowdefy/e2e-utils/fixtures';
test('homepage loads', async ({ ldf }) => {
await ldf.goto('/');
await expect(ldf.page).toHaveTitle(/My App/);
});
ldf.block('id').do.*)Interact with blocks using Playwright-style action verbs:
test('submit form', async ({ ldf }) => {
await ldf.goto('/contact');
// Text inputs use .fill()
await ldf.block('name_input').do.fill('John Doe');
await ldf.block('email_input').do.fill('[email protected]');
// Selectors use .select()
await ldf.block('category').do.select('Support');
// Buttons use .click()
await ldf.block('submit_btn').do.click();
// Clear an input
await ldf.block('name_input').do.clear();
});
ldf.block('id').expect.*)Common assertions like visible, hidden, disabled, enabled are auto-provided for every block. Validation assertions work automatically too:
test('validates required fields', async ({ ldf }) => {
await ldf.goto('/contact');
await ldf.block('submit_btn').do.click();
// Common assertions - available on every block
await ldf.block('name_input').expect.visible();
await ldf.block('submit_btn').expect.disabled();
// Validation - available on every block
await ldf.block('name_input').expect.validationError();
await ldf.block('email_input').expect.validationError({ message: 'Email is required' });
// Block-specific assertions
await ldf.block('name_input').expect.value('');
await ldf.block('submit_btn').expect.loading();
});
ldf.state())// Assert state values
await ldf.state('submitted').expect.toBe(true);
await ldf.state('form.email').expect.toBe('[email protected]');
// Set state directly (useful for test setup)
await ldf.state('form.mode').do.set('edit');
// Get raw state value
const email = await ldf.state('form.email').value();
const fullState = await ldf.state().value();
ldf.url() / ldf.urlQuery())// Assert URL
await ldf.url().expect.toBe('/thank-you');
await ldf.url().expect.toMatch(/\/tickets\/\d+/);
// Query params
await ldf.urlQuery('filter').expect.toBe('active');
await ldf.urlQuery('page').do.set('2');
// Get raw values
const currentUrl = ldf.url().value();
const filterValue = ldf.urlQuery('filter').value();
ldf.request())// Wait for request to finish
await ldf.request('send_message').expect.toFinish();
// Assert response
await ldf.request('fetch_users').expect.toHaveResponse([{ id: 1 }]);
// Assert payload sent to request
await ldf.request('search').expect.toHavePayload({ query: 'widget' });
// Get raw response
const response = await ldf.request('fetch_users').response();
// Mock for this test only (defaults to current page)
await ldf.mock.request('search_products', { response: [{ id: 1 }] });
// Mock with error
await ldf.mock.request('fetch_data', { error: 'Service unavailable' });
// Mock specific page's request
await ldf.mock.request('fetch_data', { pageId: 'admin-dashboard', response: [] });
// Mock API endpoints
await ldf.mock.api('external_api', { response: { status: 'ok' }, method: 'POST' });
// Raw Playwright page for custom assertions
await expect(ldf.page).toHaveTitle(/My App/);
// Raw block locator for custom assertions
const locator = ldf.block('submit_btn').locator();
await expect(locator).toHaveAttribute('data-loading', 'true');
// Block state and validation
const blockState = await ldf.block('name_input').state();
const validation = await ldf.block('email_input').validation();
import { test, expect } from '@lowdefy/e2e-utils/fixtures';
test('create ticket workflow', async ({ ldf }) => {
await ldf.goto('/tickets/new');
// Fill form
await ldf.block('title_input').do.fill('Bug report');
await ldf.block('description_input').do.fill('Login button not working');
await ldf.block('priority_selector').do.select('High');
// Submit
await ldf.block('submit_btn').do.click();
// Verify
await ldf.request('create_ticket').expect.toFinish();
await ldf.url().expect.toMatch(/\/tickets\/\d+/);
await ldf.state('ticket.status').expect.toBe('open');
});
Define mocks that apply to all tests:
# e2e/mocks.yaml
requests:
# Mock Atlas Search (can't run in tests)
- requestId: atlas_search
response:
- _id: doc-1
title: Search Result
# Mock with pageId and wildcards
- requestId: fetch_*
pageId: admin-*
response: []
api:
- endpointId: external_api
method: POST
response:
status: ok
If your app uses auth, define a default test user in mocks.yaml:
# e2e/mocks.yaml
user:
name: Test User
email: [email protected]
roles:
- admin
Override the user per test with ldf.user(), or clear it with ldf.user(null):
test('admin can access dashboard', async ({ ldf }) => {
// Default user from mocks.yaml is used
await ldf.goto('/dashboard');
await ldf.url().expect.toBe('/dashboard');
});
test('unauthenticated user is redirected', async ({ ldf }) => {
await ldf.user(null);
await ldf.goto('/dashboard');
await ldf.url().expect.toBe('/login');
});
test('viewer cannot access admin page', async ({ ldf }) => {
await ldf.user({ name: 'Viewer', roles: ['viewer'] });
await ldf.goto('/admin-settings');
await ldf.url().expect.toBe('/login');
});
The user object maps directly to lowdefy.user — no session callbacks or transforms are applied.
// e2e/playwright.config.js
import { createConfig } from '@lowdefy/e2e-utils/config';
export default createConfig({
appDir: './', // Where lowdefy.yaml is (default: './')
port: 3000, // Server port (default: 3000)
testDir: 'e2e', // Test directory (default: 'e2e')
testMatch: '**/*.spec.js',
timeout: 180000, // WebServer timeout in ms (default: 180000)
});
Use createBlockHelper to define a block's e2e helper. Common methods (visible, hidden, disabled, enabled, validation) are auto-provided from the locator:
// blocks-antd/src/blocks/MyBlock/e2e.js
import { createBlockHelper } from '@lowdefy/e2e-utils';
import { expect } from '@playwright/test';
const locator = (page, blockId) => page.locator(`#${blockId}_input`);
export default createBlockHelper({
locator,
do: {
fill: (page, blockId, val) => locator(page, blockId).fill(val),
clear: (page, blockId) => locator(page, blockId).clear(),
},
expect: {
// Override defaults if needed (e.g., Selector uses CSS class for disabled)
// disabled: (page, blockId) => expect(locator(page, blockId)).toHaveClass(/my-disabled/),
value: (page, blockId, val) => expect(locator(page, blockId)).toHaveValue(val),
},
});
Add the subpath export in package.json:
"exports": {
"./e2e/MyBlock": "./dist/blocks/MyBlock/e2e.js"
}
See ARCHITECTURE.md for full technical details.