6# UI4 Convert Tests
7
8## Overview
9
10After 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.
11
12## When to Use
13
14- UI changes are finalized and ready for test fixes
15- CI is failing on tests due to your UI changes
16- Before opening a PR to ensure tests pass
17
18## Process
19
20### Step 1: Analyze What Changed
21
22**Goal:** Understand the _nature_ of your changes to predict test impact.
23
24```bash
25# Get changed UI files
26git diff main --name-only -- 'packages/ui/src/**/*.tsx' 'packages/ui/src/**/*.css'
27```
28
29**For each changed file, categorize the changes:**
30
31#### A. Selector Changes (IDs, classes)
32
33```bash
34git diff main -- <file> | grep -E '^\-.*className|^\-.*id=|^\+.*className|^\+.*id='
35```
36
37#### B. Structural Changes (elements moved)
38
39Look for components being:
40
41- Moved INTO a popup, drawer, or dropdown
42- Wrapped in new parent elements
43- Made conditional
44
45```bash
46git diff main -- <file> | grep -E 'Popup|PopupList|Drawer|Dropdown'
47```
48
49#### C. Text/Label Changes
50
51```bash
52# Translation keys
53git diff main -- <file> | grep -E "t\('|i18n\.t\("
54
55# Hardcoded text
56git diff main -- <file> | grep -E 'placeholder=|aria-label='
57```
58
59**Build a change summary:**
60
61| Change Type | What Changed | Test Impact |
62| ----------- | --------------------------------------------- | ------------------- |
63| Selector | .btn:has-text("Create") → #create-new-doc | Update locators |
64| Structure | Button moved into popup | Add popup open step |
65| Text | "Search by ID" → "Search" | Update assertions |
66
67### Step 2: Find Affected Tests
68
69**Search strategy:** Cast a wide net, then narrow down.
70
71```bash
72# Search for component name references (not just selectors)
73grep -rn "QueryPreset\|query-preset\|preset" test/**/*.ts --include="*.spec.ts" --include="*.ts"
74
75# Search for specific selectors from Step 1
76grep -rn "\.list-header\|Create New\|#create-new" test/**/*.ts
77```
78
79**Key test locations:**
80
81| Pattern | Where to Look |
82| -------------------------------------- | ----------------------------- |
83| Component-specific | test/<feature>/e2e.spec.ts |
84| Shared helpers | test/<feature>/helpers/*.ts |
85| Cross-cutting | test/__helpers/e2e/*.ts |
86| Multiple features using same component | Search ALL test dirs |
87
88**Don't just search for exact selectors!** Also search for:
89
90- Component names (e.g., QueryPreset, ListHeader)
91- Feature names (e.g., preset, filter, search)
92- Text content that changed (e.g., "Create New", "Search by")
93
94### Step 3: Analyze Test Dependencies
95
96**Before fixing, understand the test:**
97
981. **Read the full test** - Understand what it's actually testing
992. **Check for helpers** - Is there a shared helper that handles this selector?
1003. **Look for patterns** - Are multiple tests doing the same thing?
101
102**If multiple tests use the same selector, create/update a helper:**
103
104```typescript
105// test/<feature>/helpers/togglePreset.ts
106export async function openCreatePreset(page: Page) {
107 await page.click('#select-preset') // Open popup first
108 await page.click('#create-new-preset')
109}
110```
111
112This centralizes the fix and prevents future duplication.
113
114### Step 4: Categorize Fixes Needed
115
116| Change Type | Fix Strategy |
117| --------------------------- | ----------------------------------------------- |
118| **Selector renamed** | Direct string replacement |
119| **Element moved to popup** | Add click to open popup before clicking element |
120| **Element moved to drawer** | Add drawer open/close handling |
121| **Text simplified** | Update assertion to match new text |
122| **Element removed** | Rework test logic or delete test |
123| **Props changed** | Update attribute assertions |
124| **Conditional rendering** | May need to set up state before element appears |
125
126### Step 5: Run Affected Tests
127
128**Run tests BEFORE making fixes to confirm they actually fail:**
129
130```bash
131# Use isolated port to avoid conflicts
132PORT=3150 pnpm test:e2e <suite> --max-failures=1
133
134# Run specific test by name
135PORT=3150 pnpm test:e2e <suite> -g "test name" --max-failures=1
136```
137
138**Document failure patterns:**
139
140- Timeout waiting for locator('.old-selector') → Selector changed
141- locator resolved to 0 elements → Element moved or removed
142- expected "New Text" received "Old Text" → Text content changed
143
144### Step 6: Apply Fixes
145
146**Priority: Fix helpers first, then individual tests.**
147
148#### Pattern 1: Selector Renamed
149
150```typescript
151// Before
152await page.click('.list-header .btn:has-text("Create")')
153
154// After - prefer IDs when available
155await page.click('#create-new-doc')
156```
157
158#### Pattern 2: Element Moved Into Popup
159
160```typescript
161// Before - direct click
162await page.click('#edit-preset')
163
164// After - open popup first
165await page.click('#select-preset') // Opens the popup
166await page.click('#edit-preset') // Now visible in popup
167```
168
169#### Pattern 3: Text Content Simplified
170
171```typescript
172// Before - specific placeholder text
173await expect(input).toHaveAttribute('placeholder', /(Search by ID)/)
174
175// After - simplified text
176await expect(input).toHaveAttribute('placeholder', 'Search')
177```
178
179#### Pattern 4: Create Reusable Helper
180
181When the same interaction is needed in multiple tests:
182
183```typescript
184// test/<feature>/helpers/interactions.ts
185export async function openEditPreset(page: Page) {
186 await page.click('#select-preset')
187 await page.click('#edit-preset')
188}
189
190// In tests - import and use
191import { openEditPreset } from './helpers/interactions.js'
192await openEditPreset(page)
193```
194
195### Step 7: Verify Fixes
196
197```bash
198# Run same tests that failed
199PORT=3150 pnpm test:e2e <suite> --max-failures=1
200```
201
202Only commit after tests pass.
203
204## Common Patterns (Real Examples)
205
206### Pattern: Buttons Moved Into Popup Menu
207
208**Symptom:** Test times out waiting for button that used to be directly visible.
209
210**Detection:** Check if buttons were wrapped in <Popup> or <PopupList>:
211
212```bash
213git diff main -- <file> | grep -E 'PopupList|Popup'
214```
215
216**Fix:** Add popup trigger click before clicking the button:
217
218```typescript
219// Before: Button was directly in toolbar
220await page.click('#edit-preset')
221
222// After: Button is now inside a popup
223await page.click('#select-preset') // Opens popup
224await page.click('#edit-preset') // Now visible
225```
226
227**Bonus:** If multiple tests need this, create a helper function.
228
229### Pattern: Class-Based Selector → ID Selector
230
231**Symptom:** .some-class or :has-text("Button Text") no longer finds element.
232
233**Detection:** Component added id= attribute:
234
235```bash
236git diff main -- <file> | grep -E '^\+.*id='
237```
238
239**Fix:** Use the more stable ID:
240
241```typescript
242// Before: Fragile class + text selector
243await page.click('.list-header .btn:has-text("Create New")')
244
245// After: Stable ID selector
246await page.click('#create-new-doc')
247```
248
249### Pattern: Placeholder/Label Text Simplified
250
251**Symptom:** Assertion fails with expected "New Text" received "Old Text".
252
253**Detection:** Translation key or hardcoded text changed:
254
255```bash
256git diff main -- <file> | grep -E 'placeholder=|t\('
257```
258
259**Fix:** Update assertion to match new text:
260
261```typescript
262// Before: Verbose placeholder
263await expect(input).toHaveAttribute('placeholder', /(Search by ID, Title)/)
264
265// After: Simplified
266await expect(input).toHaveAttribute('placeholder', 'Search')
267```
268
269### Pattern: Same Fix Needed Across Multiple Tests
270
271**Symptom:** Several tests in different suites fail with similar selector issues.
272
273**Detection:**
274
275```bash
276# Find all tests using the old selector
277grep -rn "old-selector\|.old-class" test/**/*.ts
278```
279
280**Fix:**
281
2821. Check if a helper already exists in test/<feature>/helpers/
2832. If yes, fix the helper (fixes all tests at once)
2843. If no, create one and refactor tests to use it
285
286### Pattern: Tests in Different Suites Share Components
287
288When you change a shared component (like ListControls, QueryPresetBar), multiple test suites may be affected.
289
290**Detection:**
291
292```bash
293# Find component name references across all tests
294grep -rn "QueryPreset\|ListControl" test/**/*.ts | cut -d: -f1 | sort -u
295```
296
297**Common cross-suite components:**
298
299- ListControls → affects any list view tests
300- QueryPresetBar → query-presets, group-by, admin tests
301- Search → i18n, admin, most collection tests
302- Button → nearly everything
303
304## Quick Reference: Common Payload Test Selectors
305
306| Component | Common Selectors |
307| ------------- | --------------------------------------------------- |
308| Search | .search-filter__input, #search-filter-input |
309| List View | .collection-list, tbody tr, .table-row |
310| Popup | .popup__content, .popup-button-list__button |
311| Modal | dialog, [id^=doc-drawer_], [id^=list-drawer_] |
312| Buttons | .btn, button[type="button"] |
313| Query Presets | #select-preset, .query-preset-bar__* |
314
315## Test Commands Reference
316
317```bash
318# Run all e2e tests for a test suite (auto-starts dev server)
319PORT=3150 pnpm test:e2e <suite-name>
320
321# Run specific test file
322PORT=3150 pnpm test:e2e test/<suite>/e2e.spec.ts
323
324# Run with headed browser (see what's happening)
325PORT=3150 pnpm test:e2e:headed test/<suite>/e2e.spec.ts
326
327# Run in debug mode (step through)
328PORT=3150 pnpm test:e2e:debug test/<suite>/e2e.spec.ts
329
330# Run specific test by name pattern
331PORT=3150 pnpm test:e2e test/<suite>/e2e.spec.ts -g "test name pattern"
332
333# Stop on first failure (useful during debugging)
334PORT=3150 pnpm test:e2e test/<suite>/e2e.spec.ts --max-failures=1
335```
336
337**Note:** The pnpm test:e2e command automatically:
338
3391. Starts a dev server if the port is free
3402. Reuses an existing dev server if the port is in use
3413. Runs playwright tests against that port
342
343## Running Tests with Isolation
344
345### Quick Start: Isolated Test Run
346
347**Pick a unique port in the 3100-3199 range:**
348
349```bash
350# Run tests on an isolated port (MongoDB auto-starts its own in-memory server)
351PORT=3150 pnpm test:e2e query-presets --max-failures=1
352```
353
354That's it for MongoDB (default). Each test run starts its own in-memory MongoDB server, so no database conflicts occur.
355
356### Postgres Isolation
357
358For Postgres tests, use a unique database per worktree/repo:
359
360```bash
361# Create a unique database for this worktree
362PGPASSWORD=payload psql -h localhost -p 5433 -U payload -c "CREATE DATABASE payload_worktree1;"
363
364# Run tests against that database
365POSTGRES_URL="postgres://payload:payload@localhost:5433/payload_worktree1" \
366 PORT=3150 pnpm test:e2e query-presets --max-failures=1
367```
368
369Or use the custom schema approach (no separate DB needed):
370
371```bash
372# Tests will use a separate schema within the same database
373PAYLOAD_DATABASE=postgres-custom-schema PORT=3150 pnpm test:e2e query-presets
374```
375
376### Why Isolation Matters
377
378| Scenario | Port Conflict? | DB Conflict? |
379| ------------------ | --------------- | ---------------------------- |
380| MongoDB in-memory | Yes (same port) | No (each run has own server) |
381| Postgres | Yes (same port) | Yes (same tables) |
382| Multiple worktrees | Yes | Yes (Postgres only) |
383
384**Solution:** Always set PORT to avoid port conflicts. For Postgres, also isolate the database.
385
386## Common Mistakes
387
388**Dev server not running or wrong port:**
389Tests read PORT env var (default 3000). Two approaches:
390
391**Option A: Use isolated port (preferred for parallel test suites)**
392
393```bash
394# Run tests on custom port - script handles everything
395PORT=3105 pnpm test:e2e test/query-presets/e2e.spec.ts
396```
397
398**Option B: Kill existing ports and use default**
399
400```bash
401# Kill all dev server ports
402lsof -ti:3000,3001,3002,3003,3004,3005,3006,3007,3008,3009 | xargs kill -9 2>/dev/null
403
404# Tests use 3000 by default
405pnpm test:e2e test/query-presets/e2e.spec.ts
406```
407
408**Wrong test suite running:**
409Each 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.
410
411**Not running tests before fixing:**
412Always verify tests actually fail before making changes. If a test passes, don't change it.
413
414**Not checking helper files:**
415Test helpers in test/*/helpers/ often contain shared selectors that affect multiple tests.
416
417**Missing popup interactions:**
418When elements move into popups, tests need to open the popup first.
419
420**Forgetting confirmation dialogs:**
421Delete actions often add confirmation modals - tests need to handle the confirm step.
422
423**Placeholder text changes:**
424Search placeholders, button labels, and other text content may change.
425
426**Modal slug mismatches:**
427When deleting/confirming actions, the modal slug may change. Check the component code for the actual slug prop passed to <Modal> or drawer components.
428
429## Example: QueryPresetBar Changes
430
431**Old structure (chips):**
432
433```typescript
434// Direct buttons visible
435await page.click('#create-new-preset')
436await page.click('#edit-preset')
437await page.click('#delete-preset')
438await page.click('.chip__remove') // clear
439```
440
441**New structure (popup dropdown):**
442
443```typescript
444// Open popup first
445await page.click('#select-preset')
446// Then click menu items
447await page.click('.popup-button-list__button:has-text("Create New")')
448await page.click('.popup-button-list__button:has-text("Edit")')
449await page.click('.popup-button-list__button:has-text("Delete")')
450// Clear uses dedicated button
451await page.click('.query-preset-bar__clear')
452```
453