Playwright Testing Patterns
This page covers the practical patterns for writing full-stack Playwright tests in a Blong suite.
Setup Pattern
Package dependencies
{
"devDependencies": {
"@playwright/test": "^1.52.0",
"@feasibleone/blong-browser": "workspace:^1.0.0",
"@feasibleone/blong-dev": "workspace:*"
},
"scripts": {
"ci-test": "blong-dev playwright",
"playwright": "blong-dev playwright",
"playwright:update": "blong-dev playwright --update-snapshots"
}
}
Playwright config
Use the shared defineBlongConfig() helper:
// playwright.config.ts
import {defineBlongConfig} from '@feasibleone/blong-browser/playwright/config';
export default defineBlongConfig();
Override any setting:
export default defineBlongConfig({
timeout: 60_000,
use: {blongPermissions: false},
});
defineBlongConfig() includes webServer entries that auto-start the blong server (port 8080)
and Vite dev server (port 5173) in CI (reuseExistingServer: !process.env.CI).
Test File Pattern
Test files use the .play.ts extension and import from @feasibleone/blong-browser/playwright:
import {test, expect} from '@feasibleone/blong-browser/playwright';
test('description', async ({portal}) => {
// portal is already logged in
await portal.menuClick('subject.object.browse');
await portal.waitForTableData();
await expect(portal.page).toHaveScreenshot('screenshot-name.png');
});
Model CRUD Pattern
For standard model pages, use the generic CRUD helpers. Widget types are auto-detected from
blong-* CSS classes in the DOM — just pass plain values:
import {test, expect} from '@feasibleone/blong-browser/playwright';
import {browseModel, createAndEditModel} from '@feasibleone/blong-browser/playwright/model';
test.describe('Entity CRUD', () => {
browseModel(test, expect, {
subject: 'realm',
object: 'entity',
searchText: 'Filter Text', // optional: filters table before screenshot
});
createAndEditModel(test, expect, {
subject: 'realm',
object: 'entity',
fields: {
'entity.entityName': 'New Entity',
'entity.entityType': 'typeA', // auto-detected as select
'entity.parentId': 'Parent Name', // auto-detected as dropdown
'entity.quantity': 42, // auto-detected as number
'entity.active': true, // auto-detected as checkbox
'entity.createdDate': '06/15/2024', // auto-detected as date
},
editFields: {
'entity.entityName': 'Edited Entity',
},
});
});
Element Selector Pattern
Form inputs — by name attribute
// Text input
await page.fill('input[name="coral.coralName"]', 'value');
// Textarea
await page.fill('textarea[name="coral.description"]', 'value');
// Number input
await page.fill('input[name="coral.maxDepth"]', '42');
Widget inputs — by id attribute (hyphens, not dots)
Widget IDs use hyphens where name attributes use dots. The model form system converts
coral.familyId to coral-familyId for IDs, because dots conflict with CSS selectors.
// Dropdown — click the wrapper by data-testid (also uses hyphens)
const dropdown = page.locator('[data-testid="coral-familyId"]');
await dropdown.click();
await page.locator('.p-dropdown-item:has-text("Acroporidae")').click();
// Checkbox — by id attribute
const cb = page.locator('input[type="checkbox"][id="coral-endangered"]');
await cb.check();
// Date/Calendar — by id attribute (no name attr on Calendar inputs)
const dateInput = page.locator('input[id="coral-discovered"]');
await dateInput.fill('06/15/2024');
await dateInput.press('Escape'); // Close the date picker overlay
// SelectButton — by text content (no field-specific selector needed)
await page.locator('.p-selectbutton [role="button"]:has-text("Soft Coral")').first().click();
Toolbar buttons — by data-testid
await page.getByTestId('editor-save').click();
await page.getByTestId('editor-edit').click();
await page.getByTestId('editor-cancel').click();
await page.getByTestId('editor-refresh').click();
Menu items — by data-testid
// Menu group (derived from first child's subject)
await page.getByTestId('portal-menu-marine').click();
// Menu item (semantic triple with dots→dashes)
await page.getByTestId('portal-menu-marine-coral-browse').click();
Table cells — by data-testid
// Cell: {fieldName}-{rowIndex}
await page.getByTestId('coral-0').click();
// Table actions
await page.getByTestId('coral-addButton').click();
await page.getByTestId('coral-deleteButton').click();
Login — by name and data-testid
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'admin');
await page.getByTestId('login-submit').click();
Screenshot Pattern
// Full page screenshot
await expect(portal.page).toHaveScreenshot('page-name.png');
// Screenshot with masked dynamic content
await expect(portal.page).toHaveScreenshot('page-name.png', {
mask: [portal.page.locator('input[name="entity.entityName"]')],
});
// Update baselines
// node --run playwright:update
Portal Helper Pattern
The Portal class provides high-level methods. Use these instead of raw selectors:
test('full flow', async ({portal}) => {
// Navigate
await portal.menuClick('marine.coral.browse');
await portal.waitForTableData();
// Fill form
await portal.fill('coral.coralName', 'Test');
await portal.fillTextarea('coral.description', 'Description');
// Save and verify
await portal.save();
await expect(portal.page).toHaveScreenshot('saved.png');
// Table interaction
await portal.tableRowClickByText('Test');
await portal.waitForFormLoad();
});
Running Tests
# CI mode (auto-starts servers via webServer config)
node --run ci-test
# Local development (servers already running)
node --run blong & # blong server on port 8080
node --run dev & # Vite dev server on port 5173
node --run playwright
# Run specific test file
node --run playwright -- test/coral.play.ts
# Update screenshot baselines
node --run playwright:update
# Run with headed browser (for debugging)
node --run playwright -- --headed
# Run with Playwright UI mode
node --run playwright -- --ui
Waiting for Form Data Pattern
When opening an existing record for editing, the form renders immediately but API data arrives
asynchronously. Use waitForFormData() after waitForFormLoad() to avoid filling fields before
the API response populates them:
await portal.waitForFormLoad(); // Form visible, skeleton gone
await portal.waitForFormData(); // At least one input has a value from the API
// Now safe to fill fields
await fillFields(portal.page, editFields);
Code Coverage
Playwright tests can collect coverage from both the server-side blong process and the browser-side
React/JS code using the --coverage flag.
Quick Start
Add --coverage to your ci-test script in the suite's package.json:
{
"scripts": {
"ci-test": "blong-dev playwright --coverage"
}
}
Run tests as usual — coverage is collected automatically and aggregated into the unified report:
# Run all Playwright tests with coverage
node --run ci-test
# Or pass --coverage directly
blong-dev playwright --coverage
How It Works
Playwright coverage collection happens in two directions simultaneously:
Server-side: When --coverage is passed, the blong-dev playwright command sets the
NODE_V8_COVERAGE environment variable. The blong server process (spawned by Playwright's
webServer config) inherits this and writes V8 coverage data to .playwright/coverage/v8/
when the process exits.
Browser-side: The @feasibleone/blong-browser/playwright package includes a coverage
fixture that runs automatically for every test. It uses Playwright's Chromium-specific
page.coverage.startJSCoverage() API to collect JavaScript coverage from the browser.
After each test, it maps Vite dev server URLs (e.g. http://localhost:5173/src/...) to
filesystem paths and writes V8-format JSON files.
After all tests complete, both sets of coverage files are copied with a pw- prefix into
core/blong-gogo/.tap/coverage/, where they are picked up by the next c8 report run.
Coverage Fixture
The coverage fixture is built into the test object exported by
@feasibleone/blong-browser/playwright. Test files do not need to import a separate
fixture — coverage is collected automatically when NODE_V8_COVERAGE is set:
import {test, expect} from '@feasibleone/blong-browser/playwright';
// Coverage is collected automatically — no extra imports needed
The fixture:
- Starts JS coverage with
resetOnNavigation: falseso coverage accumulates across multi-page workflows (login → navigation → form interaction) - Writes browser coverage files named
browser-{md5hash}.jsonto theNODE_V8_COVERAGEdir - Skips silently when
NODE_V8_COVERAGEis not set (normal development) - Skips silently in non-Chromium browsers (Firefox/WebKit don't support
page.coverage)
Composing Fixtures Manually
If you create a custom test object (e.g., extending the portal fixture with additional fixtures), you can compose the coverage fixture:
import {test as baseTest} from '@feasibleone/blong-browser/playwright';
import {coverageFixture} from '@feasibleone/blong-browser/playwright/coverage';
import {addExtraFixture} from './my-fixture';
// Compose coverage + portal + extra fixture
const test = coverageFixture(baseTest);
test.extend({/* your extra fixtures */});
CI Integration
In CI, coverage flows through the Rush pipeline:
rush ci-test— runs all test passes includingblong-dev playwright --coverage, which writes coverage data tocore/blong-gogo/.tap/coverage/rush ci-coverage— runscore/blong-gogo/run-coverage.sh, which aggregates all coverage (tap + Playwright) into a singlelcov.inforomeovs/lcov-reporter-action— posts a coverage summary on the PR
The run-coverage.sh script detects Playwright coverage files automatically:
pw_count=$(ls core/blong-gogo/.tap/coverage/pw-*.json 2>/dev/null | wc -l)
if [ "$pw_count" -gt 0 ]; then
echo "run-coverage.sh: Including $pw_count Playwright coverage file(s)"
fi
Path Mapping
Browser coverage URLs from Vite (e.g. http://localhost:5173/src/components/Portal.tsx)
are mapped to filesystem paths by stripping the protocol/host prefix and resolving
relative to the project root. Files from node_modules, Vite's HMR client, and
@react-refresh are excluded automatically.
Stateful Mock Pattern (Dirty Cycle)
When the mock adapter mutates an in-memory array, records persist across test runs. This means edit tests may find the record already contains the expected values, leaving react-hook-form in a clean state (Save button disabled).
The createAndEditModel() helper uses a dirty cycle to work around this:
// 1. Text/textarea fields get a random suffix → form becomes dirty → save.
// Non-text widgets (date/select/dropdown/number) keep their value — the
// editFields values already differ from the loaded record, so this first
// save is skipped and a single save is used.
await fillFields(page, addSuffix(editFields, randomSuffix));
await portal.save();
// 2. Fill with actual values → form is dirty again → save
await fillFields(page, editFields);
await portal.save();
The addSuffix() helper only modifies text and textarea values, leaving selects, dropdowns,
checkboxes, and dates unchanged. A search option filters the browse table to a specific (e.g.
test-created) row before opening it in the edit test, so the edit never targets seeded data.