npm.io
7.1.0 • Published yesterdayCLI

@lowdefy/e2e-utils

Licence
Apache-2.0
Version
7.1.0
Deps
4
Size
160 kB
Vulns
0
Weekly
0
Stars
3.0K

@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 config
  • e2e/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.

Keywords