| name | ui4-convert-tests |
| description | Use when UI changes are complete and e2e tests need updating. Analyzes what changed in UI components and systematically finds/fixes affected tests. |
UI4 Convert Tests
Overview
After completing UI changes, this skill systematically identifies and fixes affected e2e tests. It analyzes the diff to understand what kind of changes were made (not just which files), then finds tests that need updates.
When to Use
- UI changes are finalized and ready for test fixes
- CI is failing on tests due to your UI changes
- Before opening a PR to ensure tests pass
Process
Step 1: Analyze What Changed
Goal: Understand the nature of your changes to predict test impact.
git diff main --name-only -- 'packages/ui/src/**/*.tsx' 'packages/ui/src/**/*.css'
For each changed file, categorize the changes:
A. Selector Changes (IDs, classes)
git diff main -- <file> | grep -E '^\-.*className|^\-.*id=|^\+.*className|^\+.*id='
B. Structural Changes (elements moved)
Look for components being:
- Moved INTO a popup, drawer, or dropdown
- Wrapped in new parent elements
- Made conditional
git diff main -- <file> | grep -E 'Popup|PopupList|Drawer|Dropdown'
C. Text/Label Changes
git diff main -- <file> | grep -E "t\('|i18n\.t\("
git diff main -- <file> | grep -E 'placeholder=|aria-label='
Build a change summary:
| Change Type | What Changed | Test Impact |
|---|
| Selector | .btn:has-text("Create") → #create-new-doc | Update locators |
| Structure | Button moved into popup | Add popup open step |
| Text | "Search by ID" → "Search" | Update assertions |
Step 2: Find Affected Tests
Search strategy: Cast a wide net, then narrow down.
grep -rn "QueryPreset\|query-preset\|preset" test/**/*.ts --include="*.spec.ts" --include="*.ts"
grep -rn "\.list-header\|Create New\|#create-new" test/**/*.ts
Key test locations:
| Pattern | Where to Look |
|---|
| Component-specific | test/<feature>/e2e.spec.ts |
| Shared helpers | test/<feature>/helpers/*.ts |
| Cross-cutting | test/__helpers/e2e/*.ts |
| Multiple features using same component | Search ALL test dirs |
Don't just search for exact selectors! Also search for:
- Component names (e.g.,
QueryPreset, ListHeader)
- Feature names (e.g.,
preset, filter, search)
- Text content that changed (e.g.,
"Create New", "Search by")
Step 3: Analyze Test Dependencies
Before fixing, understand the test:
- Read the full test - Understand what it's actually testing
- Check for helpers - Is there a shared helper that handles this selector?
- Look for patterns - Are multiple tests doing the same thing?
If multiple tests use the same selector, create/update a helper:
export async function openCreatePreset(page: Page) {
await page.click('#select-preset')
await page.click('#create-new-preset')
}
This centralizes the fix and prevents future duplication.
Step 4: Categorize Fixes Needed
| Change Type | Fix Strategy |
|---|
| Selector renamed | Direct string replacement |
| Element moved to popup | Add click to open popup before clicking element |
| Element moved to drawer | Add drawer open/close handling |
| Text simplified | Update assertion to match new text |
| Element removed | Rework test logic or delete test |
| Props changed | Update attribute assertions |
| Conditional rendering | May need to set up state before element appears |
Step 5: Run Affected Tests
Run tests BEFORE making fixes to confirm they actually fail:
PORT=3150 pnpm test:e2e <suite> --max-failures=1
PORT=3150 pnpm test:e2e <suite> -g "test name" --max-failures=1
Document failure patterns:
Timeout waiting for locator('.old-selector') → Selector changed
locator resolved to 0 elements → Element moved or removed
expected "New Text" received "Old Text" → Text content changed
Step 6: Apply Fixes
Priority: Fix helpers first, then individual tests.
Pattern 1: Selector Renamed
await page.click('.list-header .btn:has-text("Create")')
await page.click('#create-new-doc')
Pattern 2: Element Moved Into Popup
await page.click('#edit-preset')
await page.click('#select-preset')
await page.click('#edit-preset')
Pattern 3: Text Content Simplified
await expect(input).toHaveAttribute('placeholder', /(Search by ID)/)
await expect(input).toHaveAttribute('placeholder', 'Search')
Pattern 4: Create Reusable Helper
When the same interaction is needed in multiple tests:
export async function openEditPreset(page: Page) {
await page.click('#select-preset')
await page.click('#edit-preset')
}
import { openEditPreset } from './helpers/interactions.js'
await openEditPreset(page)
Step 7: Verify Fixes
PORT=3150 pnpm test:e2e <suite> --max-failures=1
Only commit after tests pass.
Common Patterns (Real Examples)
Pattern: Buttons Moved Into Popup Menu
Symptom: Test times out waiting for button that used to be directly visible.
Detection: Check if buttons were wrapped in <Popup> or <PopupList>:
git diff main -- <file> | grep -E 'PopupList|Popup'
Fix: Add popup trigger click before clicking the button:
await page.click('#edit-preset')
await page.click('#select-preset')
await page.click('#edit-preset')
Bonus: If multiple tests need this, create a helper function.
Pattern: Class-Based Selector → ID Selector
Symptom: .some-class or :has-text("Button Text") no longer finds element.
Detection: Component added id= attribute:
git diff main -- <file> | grep -E '^\+.*id='
Fix: Use the more stable ID:
await page.click('.list-header .btn:has-text("Create New")')
await page.click('#create-new-doc')
Pattern: Placeholder/Label Text Simplified
Symptom: Assertion fails with expected "New Text" received "Old Text".
Detection: Translation key or hardcoded text changed:
git diff main -- <file> | grep -E 'placeholder=|t\('
Fix: Update assertion to match new text:
await expect(input).toHaveAttribute('placeholder', /(Search by ID, Title)/)
await expect(input).toHaveAttribute('placeholder', 'Search')
Pattern: Same Fix Needed Across Multiple Tests
Symptom: Several tests in different suites fail with similar selector issues.
Detection:
grep -rn "old-selector\|.old-class" test/**/*.ts
Fix:
- Check if a helper already exists in
test/<feature>/helpers/
- If yes, fix the helper (fixes all tests at once)
- If no, create one and refactor tests to use it
Pattern: Tests in Different Suites Share Components
When you change a shared component (like ListControls, QueryPresetBar), multiple test suites may be affected.
Detection:
grep -rn "QueryPreset\|ListControl" test/**/*.ts | cut -d: -f1 | sort -u
Common cross-suite components:
ListControls → affects any list view tests
QueryPresetBar → query-presets, group-by, admin tests
Search → i18n, admin, most collection tests
Button → nearly everything
Quick Reference: Common Payload Test Selectors
| Component | Common Selectors |
|---|
| Search | .search-filter__input, #search-filter-input |
| List View | .collection-list, tbody tr, .table-row |
| Popup | .popup__content, .popup-button-list__button |
| Modal | dialog, [id^=doc-drawer_], [id^=list-drawer_] |
| Buttons | .btn, button[type="button"] |
| Query Presets | #select-preset, .query-preset-bar__* |
Test Commands Reference
PORT=3150 pnpm test:e2e <suite-name>
PORT=3150 pnpm test:e2e test/<suite>/e2e.spec.ts
PORT=3150 pnpm test:e2e:headed test/<suite>/e2e.spec.ts
PORT=3150 pnpm test:e2e:debug test/<suite>/e2e.spec.ts
PORT=3150 pnpm test:e2e test/<suite>/e2e.spec.ts -g "test name pattern"
PORT=3150 pnpm test:e2e test/<suite>/e2e.spec.ts --max-failures=1
Note: The pnpm test:e2e command automatically:
- Starts a dev server if the port is free
- Reuses an existing dev server if the port is in use
- Runs playwright tests against that port
Running Tests with Isolation
Quick Start: Isolated Test Run
Pick a unique port in the 3100-3199 range:
PORT=3150 pnpm test:e2e query-presets --max-failures=1
That's it for MongoDB (default). Each test run starts its own in-memory MongoDB server, so no database conflicts occur.
Postgres Isolation
For Postgres tests, use a unique database per worktree/repo:
PGPASSWORD=payload psql -h localhost -p 5433 -U payload -c "CREATE DATABASE payload_worktree1;"
POSTGRES_URL="postgres://payload:payload@localhost:5433/payload_worktree1" \
PORT=3150 pnpm test:e2e query-presets --max-failures=1
Or use the custom schema approach (no separate DB needed):
PAYLOAD_DATABASE=postgres-custom-schema PORT=3150 pnpm test:e2e query-presets
Why Isolation Matters
| Scenario | Port Conflict? | DB Conflict? |
|---|
| MongoDB in-memory | Yes (same port) | No (each run has own server) |
| Postgres | Yes (same port) | Yes (same tables) |
| Multiple worktrees | Yes | Yes (Postgres only) |
Solution: Always set PORT to avoid port conflicts. For Postgres, also isolate the database.
Common Mistakes
Dev server not running or wrong port:
Tests read PORT env var (default 3000). Two approaches:
Option A: Use isolated port (preferred for parallel test suites)
PORT=3105 pnpm test:e2e test/query-presets/e2e.spec.ts
Option B: Kill existing ports and use default
lsof -ti:3000,3001,3002,3003,3004,3005,3006,3007,3008,3009 | xargs kill -9 2>/dev/null
pnpm test:e2e test/query-presets/e2e.spec.ts
Wrong test suite running:
Each test suite (fields, query-presets, localization, etc.) has its own Payload config. Tests will fail or behave unexpectedly if the wrong dev server is running.
Not running tests before fixing:
Always verify tests actually fail before making changes. If a test passes, don't change it.
Not checking helper files:
Test helpers in test/*/helpers/ often contain shared selectors that affect multiple tests.
Missing popup interactions:
When elements move into popups, tests need to open the popup first.
Forgetting confirmation dialogs:
Delete actions often add confirmation modals - tests need to handle the confirm step.
Placeholder text changes:
Search placeholders, button labels, and other text content may change.
Modal slug mismatches:
When deleting/confirming actions, the modal slug may change. Check the component code for the actual slug prop passed to <Modal> or drawer components.
Example: QueryPresetBar Changes
Old structure (chips):
await page.click('#create-new-preset')
await page.click('#edit-preset')
await page.click('#delete-preset')
await page.click('.chip__remove')
New structure (popup dropdown):
await page.click('#select-preset')
await page.click('.popup-button-list__button:has-text("Create New")')
await page.click('.popup-button-list__button:has-text("Edit")')
await page.click('.popup-button-list__button:has-text("Delete")')
await page.click('.query-preset-bar__clear')