code-docs/utils/e2e-utils.md
Playwright testing utilities for Lowdefy applications with a locator-first API.
Source: packages/utils/e2e-utils/
Updated: 2026-02-11
PR: #1982 (Closes #1970, #1977, #1981, #1983)
Provides e2e testing infrastructure for Lowdefy apps:
┌─────────────────────────────────────────────────────────────────────┐
│ playwright.config.js │
│ createConfig({ appDir, port }) │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
Phase 1: BUILD Phase 2: FIXTURES Phase 3: TESTS
(before tests) (Playwright setup) (your test code)
Playwright's webServer runs before tests:
NEXT_PUBLIC_LOWDEFY_E2E=true npx lowdefy build && npx lowdefy start
The NEXT_PUBLIC_LOWDEFY_E2E=true flag exposes window.lowdefy in browser for state/validation access.
Manifest Generation (lazy, first worker):
types.json for block type → package mappinge2e-manifest.json with helper import paths{
"pages": {
"contact": {
"name_input": {
"type": "TextInput",
"helper": "@lowdefy/blocks-antd/e2e/TextInput"
}
}
}
}
Worker scope (shared across tests):
┌──────────────────────────────────────────────────────────────┐
│ manifest Loads e2e-manifest.json │
│ helperRegistry Lazy-loading cache for block e2e modules │
│ staticMocks Loads mocks.yaml once │
└──────────────────────────────────────────────────────────────┘
Test scope (fresh per test):
┌──────────────────────────────────────────────────────────────┐
│ ldf Main test API (createPageManager) │
│ mockManager Per-test route handlers │
└──────────────────────────────────────────────────────────────┘
The ldf object provides the locator-first API:
ldf.block('id').do.fill('value'); // Block actions
ldf.block('id').expect.visible(); // Block assertions
ldf.request('id').expect.toFinish(); // Request assertions
ldf.state('key').expect.toBe(value); // State assertions
ldf.mock.request('id', { response }); // Inline mocking
src/core/)| File | Purpose |
|---|---|
navigation.js | goto(), waitForPage() - navigation with Lowdefy ready detection |
requests.js | Request state access, toFinish(), toHavePayload() assertions |
state.js | State access via page.evaluate() into window.lowdefy |
url.js | URL and query parameter assertions |
validation.js | Block validation state access |
locators.js | Common locator patterns |
userCookie.js | Set/clear lowdefy_e2e_user cookie via browserContext.addCookies() |
src/proxy/)| File | Purpose |
|---|---|
createPageManager.js | Creates the ldf object with all APIs |
createBlockMethodProxy.js | Proxy that routes do.*/expect.* to block helpers |
createBlockHelper.js | Factory for block e2e helpers with auto-provided methods |
createHelperRegistry.js | Lazy-loading cache for block helper imports |
src/mocking/)| File | Purpose |
|---|---|
createMockManager.js | Per-test mock state, route interception |
loadStaticMocks.js | Parses mocks.yaml, applies wildcards |
src/init/)| File | Purpose |
|---|---|
index.js | CLI entry point (npx @lowdefy/e2e-utils) |
detectApps.js | Finds Lowdefy apps in app/ or apps/ |
generateFiles.js | Creates e2e folder structure from templates |
updateGitignore.js | Adds test artifacts to .gitignore |
src/config.js)createConfig({ appDir, port, timeout, ... }) // Single app
createMultiAppConfig({ apps: [...] }) // Monorepo
| Parameter | Type | Default | Description |
|---|---|---|---|
appDir | string | './' | Path to Lowdefy app root |
buildDir | string | '.lowdefy/server/build' | Build output directory (relative to appDir) |
mocksFile | string | 'e2e/mocks.yaml' | Static mocks file path (relative to appDir) |
port | number | 3000 | Port for the test server |
testDir | string | 'e2e' | Directory containing test files |
testMatch | string | '**/*.spec.js' | Glob pattern for matching test files |
timeout | number | 180000 | Build + server start timeout (ms) |
screenshot | string | 'only-on-failure' | 'off', 'on', or 'only-on-failure' |
outputDir | string | 'test-results' | Directory for test artifacts |
Sets environment variables for fixtures: LOWDEFY_BUILD_DIR (absolute path to build artifacts) and LOWDEFY_E2E_MOCKS_FILE (absolute path to mocks file, if it exists).
| Parameter | Type | Default | Description |
|---|---|---|---|
apps | array | [] | Array of app definitions (see below) |
testDir | string | 'e2e' | Root test directory |
testMatch | string | '**/*.spec.js' | Glob for test files |
timeout | number | 180000 | Build + server start timeout (ms) |
screenshot | string | 'only-on-failure' | Screenshot mode |
outputDir | string | 'test-results' | Artifacts directory |
Each app entry: { name: string, appDir: string, port: number }. Creates separate Playwright projects and webServer entries per app. Build dir is stored in project.metadata.buildDir for fixture access.
Pattern: Get the thing, then do something with it.
// Navigation
ldf.goto('/path') // Navigate and wait for Lowdefy ready
ldf.waitForPage('/path') // Wait for URL change + Lowdefy ready
ldf.pageId // Current page ID (getter)
ldf.page // Raw Playwright page object
// Block
ldf.block('id') // Returns block locator object
.do.fill('value') // Action methods (type-specific)
.expect.visible() // Assertion methods
.locator() // Raw Playwright locator
.state() // Block's state value
.validation() // Block's validation object
// Request
ldf.request('id') // Returns request locator object
.expect.toFinish(opts?) // Wait for loading: false
.expect.toHaveResponse(data, opts?) // Assert partial response match
.expect.toHavePayload(data, opts?) // Assert partial payload match
.response() // Raw response data
.state() // Full request state object
// State
ldf.state('key') // Returns state locator object
.do.set(value) // Set state value
.expect.toBe(value, opts?) // Assert state value
.value() // Get raw value
ldf.state() // No key: returns { value() } for full state
// URL
ldf.url() // Returns URL locator object
.expect.toBe('/path', opts?) // Assert exact path
.expect.toMatch(/pattern/, opts?) // Assert regex match
.value() // Get current URL string
// URL Query
ldf.urlQuery('key') // Returns query param locator
.do.set('value') // Set query parameter
.expect.toBe('value', opts?) // Assert query parameter value
.value() // Get current value
// Mocking
ldf.mock.request('id', { response, error, pageId }) // Mock request
ldf.mock.api('id', { response, error, method }) // Mock API endpoint
ldf.mock.getCapturedRequest('id') // Get captured { payload, timestamp }
ldf.mock.clearCapturedRequests() // Clear captured payloads
All opts parameters accept { timeout } — see Timeout Configuration below.
The createBlockHelper factory auto-provides common methods:
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),
},
get: {
value: (page, blockId) => locator(page, blockId).inputValue(),
},
expect: {
// visible, hidden, disabled, enabled, validationError auto-provided
value: (page, blockId, val) => expect(locator(page, blockId)).toHaveValue(val),
},
});
Factory parameters: { locator, do, get, expect }
locator(page, blockId) — returns Playwright locator (required)do — action methods: (page, blockId, ...args) => Promiseget — getter methods: (page, blockId) => Promise<value>expect — assertion methods that override/extend auto-provided onesNote:
getmethods are defined in the helper object butcreatePageManager.block()only createsdoandexpectproxies. Thegetmethods are not yet surfaced throughldf.block('id').get.*.
Auto-provided expect methods:
visible, hidden — derived from locator via toBeVisible()/toBeHidden()disabled, enabled — default uses toBeDisabled()/toBeEnabled()validationError(params?), validationWarning(params?) — optional { message } to assert specific messagevalidationSuccess — from core/validation.jsMethod resolution order (later overrides earlier):
commonExpect — visible, hidden, disabled, enabledvalidationExpect — validationError, validationWarning, validationSuccessexpect — block-specific assertions and overridesBlocks CAN override auto-provided methods. Example: Selector overrides disabled/enabled because Ant Design selects use CSS classes instead of the HTML disabled attribute.
Key insight: E2e helpers must resolve from server's node_modules, not test project's.
// createHelperRegistry.js
import { createRequire } from 'module';
const serverRequire = createRequire(path.join(serverDir, 'package.json'));
const resolvedPath = serverRequire.resolve('@lowdefy/blocks-antd/e2e/TextInput');
This allows test projects to work without block packages as direct dependencies.
requests:
- requestId: atlas_search
response: [{ _id: 'doc-1' }]
- requestId: fetch_* # Wildcards supported
pageId: admin-*
response: []
api:
- endpointId: external_api
method: POST
response: { status: ok }
await ldf.mock.request('search', { response: [] });
await ldf.mock.request('fetch', { error: 'Not found' });
await ldf.mock.api('external', { response: { ok: true } });
Mocks intercept at HTTP layer via Playwright's page.route(). Inline mocks override static mocks for the same requestId.
Both mockRequest and mockApi accept { error: 'message' }. Error mocks return status 500 with body { name: 'Error', message: '<error string>' }.
# Static error mock
requests:
- requestId: atlas_search
error: Atlas Search not available in test
mockApi supports optional method parameter. When provided, only matching HTTP methods are intercepted; others pass through via route.continue().
await ldf.mock.api('webhook', { method: 'POST', response: { ok: true } });
// GET /api/endpoints/webhook passes through to real server
// POST /api/endpoints/webhook returns mocked response
All mocked requests automatically capture the request payload:
await ldf.mock.request('search', { response: [] });
await ldf.block('search_btn').do.click();
const captured = ldf.mock.getCapturedRequest('search');
// { payload: { query: 'widget' }, timestamp: 1707600000000 }
ldf.mock.clearCapturedRequests();
Payload is extracted from the POST body's payload field (matching Lowdefy's request wire format).
| Method | Asserts | Comparison | Source |
|---|---|---|---|
toFinish(opts?) | loading === false | Exact | Engine request state |
toHaveResponse(data, opts?) | response contains | Partial deep match via objectContains | Server response in engine |
toHavePayload(data, opts?) | payload contains | Partial deep match via objectContains | Client-side evaluated payload |
toHaveResponse — partial match: actual response only needs to contain all keys from expected (can have extra keys)toHavePayload — partial match: actual payload only needs to contain all keys from expected (can have extra keys)The ldf fixture provides a user() method for setting the test user per browser context.
await ldf.user({ id: 'test', name: 'Test', roles: ['admin'] }); // Set user
await ldf.user(null); // Clear user
The mocks.yaml file supports a user key for a default test user applied to all tests automatically:
user:
name: Test User
email: [email protected]
roles:
- admin
File: src/mocking/loadStaticMocks.js reads the user key.
File: src/fixtures/index.js applies it via setUserCookie(page, staticMocks.user) during test setup.
ldf.user(obj) calls setUserCookie(page, obj) in src/core/userCookie.jssetUserCookie encodes the user as base64(JSON.stringify(obj)) and sets the lowdefy_e2e_user cookie via page.context().addCookies()server-e2eserver-e2e/getServerSession reads the cookie and returns { user } as the sessioncreateAuthorize(session) in @lowdefy/apiThe user object maps directly — no userFields, no session callbacks. Whatever the test sets is exactly what lowdefy.user receives.
ldf.user(null) or ldf.user(undefined) calls clearUserCookie(page) which removes the cookie via page.context().clearCookies({ name: 'lowdefy_e2e_user' }).
See server-e2e.md for the server-side implementation.
Block packages export e2e helpers at subpaths:
// blocks-antd/package.json
{
"exports": {
"./e2e/TextInput": "./dist/blocks/TextInput/e2e.js",
"./e2e/Button": "./dist/blocks/Button/e2e.js"
}
}
The manifest generator reads types.json to map block types to packages, then constructs import paths.
Decision: Locator-first pattern (ldf.block('id').do.fill())
Why:
Trade-off: Slightly more verbose than action-first, but clarity wins.
Decision: Use createRequire from server's node_modules
Why:
.lowdefy/server/node_modulesImplementation: createHelperRegistry.js uses createRequire(serverDir + '/package.json') to resolve from server context.
Decision: Worker scope for manifest/registry/staticMocks, test scope for ldf/mockManager
Why:
| Context | Default | Configurable | Source |
|---|---|---|---|
| Build + server start | 180000ms (3 min) | createConfig({ timeout }) | config.js |
waitForReady (page load) | 30000ms | No (hardcoded) | navigation.js |
| State assertions | 5000ms | { timeout } in opts | state.js |
| Request assertions | 30000ms | { timeout } in opts | requests.js |
| URL assertions | 5000ms | { timeout } in opts | url.js |
| Validation assertions | 5000ms | { timeout } in opts | validation.js |
await ldf.request('id').expect.toFinish({ timeout: 60000 });
await ldf.state('key').expect.toBe(value, { timeout: 10000 });
| Variable | Set By | Used By | Purpose |
|---|---|---|---|
NEXT_PUBLIC_LOWDEFY_E2E | webServer command | Client runtime | Exposes window.lowdefy for state/validation access |
LOWDEFY_BUILD_DIR | createConfig() | Fixtures | Absolute path to build artifacts for manifest/helper resolution |
LOWDEFY_E2E_MOCKS_FILE | createConfig() | Fixtures | Absolute path to static mocks YAML file |
LOWDEFY_E2E_MONGODB_URI | User (.env.e2e.local) or configureMdb() | mdb fixture | MongoDB test database connection string |
NEXT_PUBLIC_LOWDEFY_E2E=true is critical: without it, window.lowdefy is not exposed and all state/request/validation assertions fail.
src/
├── index.js # Main exports
├── config.js # createConfig, createMultiAppConfig
├── core/
│ ├── index.js
│ ├── locators.js
│ ├── navigation.js
│ ├── requests.js
│ ├── state.js
│ ├── url.js
│ └── validation.js
├── fixtures/
│ └── index.js # Playwright test.extend()
├── init/
│ ├── index.js # CLI entry
│ ├── detectApps.js
│ ├── generateFiles.js
│ ├── updateGitignore.js
│ └── templates/
│ ├── playwright.config.js.template
│ ├── example.spec.js.template
│ ├── fixtures.js.template
│ ├── mocks.yaml.template
│ ├── mongodb.spec.js.template
│ ├── env.e2e.template
│ └── README.md.template
├── mocking/
│ ├── index.js
│ ├── createMockManager.js
│ └── loadStaticMocks.js
├── proxy/
│ ├── createBlockHelper.js
│ ├── createBlockMethodProxy.js
│ ├── createHelperRegistry.js
│ └── createPageManager.js
└── testPrep/
├── extractBlockMap.js
└── generateManifest.js
@lowdefy/blocks-antd # Block e2e helpers
@lowdefy/blocks-basic # Block e2e helpers
js-yaml # Parse mocks.yaml
prompts # CLI prompts
peerDependencies:
@playwright/test # Testing framework