Back to Lowdefy

@lowdefy/e2e-utils

code-docs/utils/e2e-utils.md

5.2.022.5 KB
Original Source

@lowdefy/e2e-utils

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)

Purpose

Provides e2e testing infrastructure for Lowdefy apps:

  • Locator-first API for blocks, requests, state, and URL
  • Request mocking (static and inline)
  • Scaffold command for project setup
  • Block helper factory for type-specific interactions

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                        playwright.config.js                         │
│  createConfig({ appDir, port })                                     │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
          ┌────────────────────┼────────────────────┐
          │                    │                    │
          ▼                    ▼                    ▼
    Phase 1: BUILD      Phase 2: FIXTURES     Phase 3: TESTS
    (before tests)      (Playwright setup)    (your test code)

Phase 1: Build Time

Playwright's webServer runs before tests:

bash
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):

  • Reads types.json for block type → package mapping
  • Walks page configs to build block lookup table
  • Produces e2e-manifest.json with helper import paths
json
{
  "pages": {
    "contact": {
      "name_input": {
        "type": "TextInput",
        "helper": "@lowdefy/blocks-antd/e2e/TextInput"
      }
    }
  }
}

Phase 2: Playwright Fixtures

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                   │
└──────────────────────────────────────────────────────────────┘

Phase 3: Test Execution

The ldf object provides the locator-first API:

javascript
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

Key Modules

Core (src/core/)

FilePurpose
navigation.jsgoto(), waitForPage() - navigation with Lowdefy ready detection
requests.jsRequest state access, toFinish(), toHavePayload() assertions
state.jsState access via page.evaluate() into window.lowdefy
url.jsURL and query parameter assertions
validation.jsBlock validation state access
locators.jsCommon locator patterns
userCookie.jsSet/clear lowdefy_e2e_user cookie via browserContext.addCookies()

Proxy (src/proxy/)

FilePurpose
createPageManager.jsCreates the ldf object with all APIs
createBlockMethodProxy.jsProxy that routes do.*/expect.* to block helpers
createBlockHelper.jsFactory for block e2e helpers with auto-provided methods
createHelperRegistry.jsLazy-loading cache for block helper imports

Mocking (src/mocking/)

FilePurpose
createMockManager.jsPer-test mock state, route interception
loadStaticMocks.jsParses mocks.yaml, applies wildcards

Init (src/init/)

FilePurpose
index.jsCLI entry point (npx @lowdefy/e2e-utils)
detectApps.jsFinds Lowdefy apps in app/ or apps/
generateFiles.jsCreates e2e folder structure from templates
updateGitignore.jsAdds test artifacts to .gitignore

Config (src/config.js)

javascript
createConfig({ appDir, port, timeout, ... })      // Single app
createMultiAppConfig({ apps: [...] })             // Monorepo

createConfig Parameters

ParameterTypeDefaultDescription
appDirstring'./'Path to Lowdefy app root
buildDirstring'.lowdefy/server/build'Build output directory (relative to appDir)
mocksFilestring'e2e/mocks.yaml'Static mocks file path (relative to appDir)
portnumber3000Port for the test server
testDirstring'e2e'Directory containing test files
testMatchstring'**/*.spec.js'Glob pattern for matching test files
timeoutnumber180000Build + server start timeout (ms)
screenshotstring'only-on-failure''off', 'on', or 'only-on-failure'
outputDirstring'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).

createMultiAppConfig Parameters

ParameterTypeDefaultDescription
appsarray[]Array of app definitions (see below)
testDirstring'e2e'Root test directory
testMatchstring'**/*.spec.js'Glob for test files
timeoutnumber180000Build + server start timeout (ms)
screenshotstring'only-on-failure'Screenshot mode
outputDirstring'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.

Locator-First API Design

Pattern: Get the thing, then do something with it.

javascript
// 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.

Block Helper Factory

The createBlockHelper factory auto-provides common methods:

javascript
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) => Promise
  • get — getter methods: (page, blockId) => Promise<value>
  • expect — assertion methods that override/extend auto-provided ones

Note: get methods are defined in the helper object but createPageManager.block() only creates do and expect proxies. The get methods are not yet surfaced through ldf.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 message
  • validationSuccess — from core/validation.js

Method resolution order (later overrides earlier):

  1. commonExpect — visible, hidden, disabled, enabled
  2. validationExpect — validationError, validationWarning, validationSuccess
  3. User-provided expect — block-specific assertions and overrides

Blocks CAN override auto-provided methods. Example: Selector overrides disabled/enabled because Ant Design selects use CSS classes instead of the HTML disabled attribute.

Helper Resolution

Key insight: E2e helpers must resolve from server's node_modules, not test project's.

javascript
// 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.

Request Mocking

Static Mocks (mocks.yaml)

yaml
requests:
  - requestId: atlas_search
    response: [{ _id: 'doc-1' }]
  - requestId: fetch_* # Wildcards supported
    pageId: admin-*
    response: []
api:
  - endpointId: external_api
    method: POST
    response: { status: ok }

Inline Mocks

javascript
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.

Error Simulation

Both mockRequest and mockApi accept { error: 'message' }. Error mocks return status 500 with body { name: 'Error', message: '<error string>' }.

yaml
# Static error mock
requests:
  - requestId: atlas_search
    error: Atlas Search not available in test

API Method Filtering

mockApi supports optional method parameter. When provided, only matching HTTP methods are intercepted; others pass through via route.continue().

javascript
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

Payload Capture

All mocked requests automatically capture the request payload:

javascript
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).

Request Assertion Comparison

MethodAssertsComparisonSource
toFinish(opts?)loading === falseExactEngine request state
toHaveResponse(data, opts?)response containsPartial deep match via objectContainsServer response in engine
toHavePayload(data, opts?)payload containsPartial deep match via objectContainsClient-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)

User Authentication

The ldf fixture provides a user() method for setting the test user per browser context.

API

javascript
await ldf.user({ id: 'test', name: 'Test', roles: ['admin'] }); // Set user
await ldf.user(null); // Clear user

Default User from mocks.yaml

The mocks.yaml file supports a user key for a default test user applied to all tests automatically:

yaml
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.

How It Works

  1. ldf.user(obj) calls setUserCookie(page, obj) in src/core/userCookie.js
  2. setUserCookie encodes the user as base64(JSON.stringify(obj)) and sets the lowdefy_e2e_user cookie via page.context().addCookies()
  3. The cookie is sent with every request to server-e2e
  4. server-e2e/getServerSession reads the cookie and returns { user } as the session
  5. Authorization runs normally via createAuthorize(session) in @lowdefy/api

The user object maps directly — no userFields, no session callbacks. Whatever the test sets is exactly what lowdefy.user receives.

Clearing the User

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.

Integration with Blocks

Block packages export e2e helpers at subpaths:

json
// 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 Trace

Locator-First vs Action-First API

Decision: Locator-first pattern (ldf.block('id').do.fill())

Why:

  • More intuitive - mirrors how you think ("get the button, click it")
  • Consistent with Playwright's locator pattern
  • Better for LLM code generation - pattern is predictable
  • Enables IDE autocomplete after getting the locator

Trade-off: Slightly more verbose than action-first, but clarity wins.

Helper Resolution from Server Context

Decision: Use createRequire from server's node_modules

Why:

  • Test projects shouldn't need block packages as dependencies
  • Blocks are already installed in .lowdefy/server/node_modules
  • Simplifies test project setup

Implementation: createHelperRegistry.js uses createRequire(serverDir + '/package.json') to resolve from server context.

Worker vs Test Scope for Fixtures

Decision: Worker scope for manifest/registry/staticMocks, test scope for ldf/mockManager

Why:

  • Manifest and helpers don't change between tests - cache them
  • Static mocks apply to all tests - load once
  • Each test needs fresh page and mock state - isolate them

Timeout Configuration

ContextDefaultConfigurableSource
Build + server start180000ms (3 min)createConfig({ timeout })config.js
waitForReady (page load)30000msNo (hardcoded)navigation.js
State assertions5000ms{ timeout } in optsstate.js
Request assertions30000ms{ timeout } in optsrequests.js
URL assertions5000ms{ timeout } in optsurl.js
Validation assertions5000ms{ timeout } in optsvalidation.js
javascript
await ldf.request('id').expect.toFinish({ timeout: 60000 });
await ldf.state('key').expect.toBe(value, { timeout: 10000 });

Environment Variables

VariableSet ByUsed ByPurpose
NEXT_PUBLIC_LOWDEFY_E2EwebServer commandClient runtimeExposes window.lowdefy for state/validation access
LOWDEFY_BUILD_DIRcreateConfig()FixturesAbsolute path to build artifacts for manifest/helper resolution
LOWDEFY_E2E_MOCKS_FILEcreateConfig()FixturesAbsolute path to static mocks YAML file
LOWDEFY_E2E_MONGODB_URIUser (.env.e2e.local) or configureMdb()mdb fixtureMongoDB 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.

Files Quick Reference

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

Dependencies

@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

See Also