@lowdefy/e2e-utils
Playwright testing utilities for Lowdefy applications with a polymorphic block API.
Installation
pnpm add -D @lowdefy/e2e-utils @playwright/test
npx playwright install chromium
Quick Start
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 from
Run tests:
pnpm e2e # Run tests
pnpm e2e:ui # Run with Playwright UI
Writing Tests
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/);
});
Block Interactions (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('john@example.com');
// 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();
});
Block Assertions (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();
});
Testing List Children (ldf.list() / ldf.block() with indexed ids)
List blocks render their slot children with runtime-prefixed ids
(legal_rows.0.toggle, legal_rows.1.toggle, ...). You can pass these indexed ids
straight into ldf.block() — the manifest stores legal_rows.$.toggle and the runtime
resolves the numeric segment automatically:
await ldf.block('legal_rows.0.toggle').do.toggle();
await ldf.block('legal_rows.0.toggle').expect.checked();
For ergonomic row addressing, use ldf.list(listId):
// Number of rows in state
const count = await ldf.list('legal_rows').count();
// Positional access (synchronous)
await ldf.list('legal_rows').row(0).block('toggle').do.toggle();
// By key (async — reads list state to find the index)
const row = await ldf.list('legal_rows').rowBy('_id', 'bbbee');
await row.block('toggle').do.toggle();
await row.block('toggle').expect.checked();
// By predicate (async)
const archived = await ldf.list('legal_rows').rowWhere((it) => it.archived === true);
await archived.block('restore_btn').do.click();
Nested lists work the same way — every level of nesting adds another numeric segment, all resolved through one templated lookup:
await ldf.block('outer.0.inner.2.button').do.click();
State (ldf.state())
// Assert state values
await ldf.state('submitted').expect.toBe(true);
await ldf.state('form.email').expect.toBe('john@example.com');
// 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();
URL (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');
// do.set navigates through the app router, so a Dynamic page re-resolves its
// content for the new query before this resolves.
await ldf.urlQuery('page').do.set('2');
// Get raw values
const currentUrl = ldf.url().value();
const filterValue = ldf.urlQuery('filter').value();
Requests (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();
Mocking Requests
// 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 Access
// 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();
Keyboard Shortcuts
A mod shortcut resolves to Cmd or Ctrl depending on the platform the browser reports, which
Playwright emulates per project — a Desktop Chrome project reports Windows even on macOS. Derive the
key from the page rather than from process.platform, or the test will press a key the app is not
listening for:
import { getShortcutModifier } from '@lowdefy/e2e-utils';
const mod = await getShortcutModifier(ldf.page);
await ldf.page.keyboard.press(`${mod}+k`);
Complete Example
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');
});
Static Mocks (mocks.yaml)
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
Testing Authentication
If your app uses auth, define a default test user in mocks.yaml:
# e2e/mocks.yaml
user:
name: Test User
email: test@example.com
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.
Playwright Config
// 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)
});
The server Playwright starts stops when the Playwright run stops, however the run ends, so a killed or timed-out run leaves no server behind.
| Variable | Effect |
|---|---|
LOWDEFY_E2E_PORT |
Runs on this port instead of the configured one, so two git worktrees can run the same suite at once. |
LOWDEFY_E2E_REUSE_SERVER=true |
Reuses a server already running on the port (for example one started with pnpm e2e:server) instead of failing the run. The global setup still checks that it serves this app's e2e build. |
Starting a Server Outside Playwright
startServer builds and starts an app's e2e server for other test runners (a vitest or Jest globalSetup) and scripts. The server stops when the process that called startServer exits, however it exits, so a killed or timed-out run leaves nothing behind.
import startServer from '@lowdefy/e2e-utils/startServer';
const server = await startServer({
appDir: './', // Where lowdefy.yaml is
port: 3191, // Fails at once if the port is already in use
env: { MY_SECRET: 'value' }, // Extra environment variables for the server
build: true, // Run `lowdefy build --server e2e` first (default: true)
});
// server.url === 'http://localhost:3191'
await server.stop(); // SIGTERM, then SIGKILL after a grace period
Adding E2E Support to a Block
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.