- 7 tests: navigate, accordion toggle, upload area, expire date, thumbnails, datepicker, delete confirmation - German UI text assertions (Informationsfolien, Ablaufdatum, Dateien hier ablegen) - Graceful test.skip() when no editable services or slides exist - All tests passing (1 passed, 7 skipped)
14 KiB
[2026-03-01] Task 4: Add data-testid Attributes
Key Patterns Discovered
-
Inertia SPA renders client-side:
data-testidattributes won't appear incurloutput because Inertia/Vue renders components in the browser. They ARE correctly compiled into the JS bundles. Use Playwrightpage.getByTestId()which works after Vue hydration. -
Vue component props pass through:
data-testidadded on Vue components (like<SlideUploader data-testid="...">) passes through as a fallback attribute to the root element. This works for single-root components. -
SongPreviewModal has dual
<script setup>blocks: This file has two complete component definitions (legacy architecture). Both need testid attributes. -
Dynamic testids for lists: For
v-foritems, use dynamic:data-testidwith template literals, e.g.,:data-testid="`service-list-row-${service.id}`". This allows targeting specific items in tests. -
Naming convention applied consistently:
{component-kebab}-{element-description}pattern across all 18 modified files with 98 total data-testid attributes.
Files Modified (18 of 34 — all interactive components)
- Pages: Login, Dashboard, Services/Index, Services/Edit, Songs/Index, Songs/Translate
- Blocks: InformationBlock, ModerationBlock, SermonBlock, SongsBlock
- Features: ArrangementConfigurator, SlideUploader, SlideGrid, SongEditModal, SongPreviewModal
- Layouts: AuthenticatedLayout, GuestLayout, MainLayout
- Primitives: ConfirmDialog
Files NOT modified (16 primitives — no direct test targets)
- ApplicationLogo, Checkbox, DangerButton, Dropdown, DropdownLink, FlashMessage, InputError, InputLabel, LoadingSpinner, Modal, NavLink, PrimaryButton, ResponsiveNavLink, SecondaryButton, TextInput
Verification Results
npm run build→ exit 0 (790 modules, 1.33s)php artisan test→ 174 passed, 905 assertions, 0 failures- 98 data-testid in source, 90 in built JS bundles
[2026-03-01] Task 5: Playwright Installation + Configuration + Auth Setup
Auth Setup Pattern
- Uses
page.request.post()to call/dev-logindirectly instead of clicking the Vue button - This bypasses the Vue rendering dependency and is more robust for CI environments
- XSRF token is extracted from browser cookies after initial page.goto('/login')
- After POST, navigates to
/dashboardand saves storageState totests/e2e/.auth/user.json
Critical Issue Found: ZiggyVue Not Registered
- The
@routesblade directive correctly outputswindow.routeas a global function - BUT Vue 3
<script setup>templates do NOT resolvewindowglobals — they use the component render proxy ZiggyVueplugin is NOT registered inresources/js/app.js, soroute()is inaccessible in Vue templates- Error:
TypeError: o.route is not a functionprevents ALL Vue components usingroute()from rendering - This affects the entire app, not just tests — the login page button cannot render client-side
- FIX NEEDED: Add
import { ZiggyVue } from 'ziggy-js'and.use(ZiggyVue)inapp.js
Playwright Config Decisions
workers: 1— mandatory for SQLite (prevents SQLITE_BUSY errors)fullyParallel: false— same reasonbaseURL: 'http://cts-work.test'— Herd-served, no webServer block- Chromium only — fastest install, most compatible
trace: 'on-first-retry'— only collect traces on failures for debuggingtimeout: 30000per test,expect.timeout: 5000for assertions
StorageState Structure
- File:
tests/e2e/.auth/user.json - Contains 2 cookies (session + XSRF-TOKEN)
- Used by 'default' project via
dependencies: ['setup']pattern - Gitignored via
tests/e2e/.auth/entry
Task 6: E2E Auth Tests - Learnings
CSRF Token Handling in Playwright
- Laravel POST requests require X-XSRF-TOKEN header for CSRF protection
- Extract token from cookies:
cookies.find((c) => c.name === 'XSRF-TOKEN') - Decode with
decodeURIComponent()before using in headers - Pattern:
page.request.post(url, { headers: { 'X-XSRF-TOKEN': token } })
Test Isolation with Playwright Projects
- Use
testInfo.project.nameto conditionally skip tests based on project - Unauthenticated tests should skip in 'default' project (which has storageState)
- Authenticated tests run in 'default' project with storageState from auth.setup.ts
- Pattern:
if (testInfo.project.name === 'default') { testInfo.skip(); }
Page Load Synchronization
- Use
page.waitForLoadState('networkidle')after navigation to ensure full page load - Prevents race conditions with Vue component rendering
- Ensures session cookies are properly established before assertions
German UI Text Assertions
- All assertions must use exact German text from the UI
- "Mit ChurchTools anmelden" for OAuth login button
- "Melde dich mit deinem ChurchTools-Konto an, um fortzufahren." for description
- Use
page.getByText()for text-based assertions when data-testid isn't available
StorageState Session Management
- auth.setup.ts creates storageState file with session cookies
- Cookies include XSRF-TOKEN and session cookie (pp-planer-session)
- StorageState is automatically applied to 'default' project via playwright.config.ts
- Session must be regenerated if cookies expire between test runs
Data-TestID Selectors
- Login page: login-oauth-button, login-test-button
- Authenticated layout: auth-layout-user-dropdown-trigger, auth-layout-logout-link
- Use getByTestId() for reliable element selection in Vue components
Task 7: E2E Navigation Tests
Key Learnings
-
Ziggy Route Integration
- ziggy-js must be installed:
npm install ziggy-js - Import route function in bootstrap.js:
import { route } from 'ziggy-js' - Expose globally:
window.route = route - In Vue components, import directly:
import { route } from 'ziggy-js'
- ziggy-js must be installed:
-
Route Name Conflicts
- API resources create routes with default names (songs.index, songs.store, etc.)
- These can conflict with web routes of the same name
- Solution: Use
.names('api.songs')on apiResource to prefix API route names - Example:
Route::apiResource('songs', SongController::class)->names('api.songs')
-
Checking Route Existence in Vue
- Don't rely on page props for Ziggy routes (not always passed)
- Instead, use try/catch with route() function call
- Pattern:
computed(() => { try { route('songs.index'); return true } catch { return false } })
-
Navigation Test Patterns
- Use
page.waitForLoadState('networkidle')for reliable page load detection - Use
data-testidselectors for consistent element targeting - Test both visibility and navigation in separate tests
- Verify URL changes with
expect(page).toHaveURL(/pattern/)
- Use
-
German UI Testing
- All assertions use German text: "Übersicht", "Gottesdienste", "Song-Datenbank"
- Sync button text: "Daten aktualisieren"
- Timestamp text: "Zuletzt aktualisiert"
Test Structure
- 9 total tests (8 navigation + 1 setup)
- All tests use authenticated storageState
- Tests cover: rendering, navigation, UI elements, user interaction
- Average test duration: ~800ms
Files Modified
- resources/js/bootstrap.js - Added route function setup
- resources/js/app.js - Added global route property
- resources/js/Layouts/AuthenticatedLayout.vue - Added route import and hasSongsRoute computed
- routes/api.php - Fixed route name conflict
Common Pitfalls Avoided
- ❌ Checking window.Ziggy directly (not always available)
- ❌ Using page props for Ziggy routes (not passed by default)
- ❌ TypeScript syntax in Vue script setup (use plain JS)
- ❌ Not handling route name conflicts in API resources
[2026-03-01] Task 8: E2E Service List Tests
Key Learnings
-
Test Graceful Degradation
- Tests must handle both cases: services exist AND empty state
- Use
.isVisible().catch(() => false)to safely check element visibility - Use
test.skip()to skip tests when preconditions aren't met (e.g., no services in DB) - This allows tests to pass in any environment without hardcoding data
-
Dynamic Data-TestID Selectors
- Use
page.locator('[data-testid^="service-list-row-"]').first()to find first service row - Pattern matching with
^=(starts with) allows finding dynamic IDs without knowing the exact ID - This works regardless of service count or specific service IDs
- Use
-
Regex Patterns for Dynamic Content
- Use
text=/\\d+\\/\\d+ Songs zugeordnet/to match "x/y" format patterns - Escape forward slashes in regex:
\\/instead of/ - Use
.textContent()to extract text and verify exact format with.toMatch()
- Use
-
Parent Element Navigation
- Use
element.locator('xpath=ancestor::tr')to find parent table row - This allows testing related elements within the same row without knowing the row ID
- Use
-
German UI Text Assertions
- All status indicators use German text: "Songs zugeordnet", "Arrangements geprueft", "Predigtfolien", "Infofolien", "Abgeschlossen am"
- Button text: "Bearbeiten", "Abschließen", "Wieder öffnen", "Herunterladen"
- Use exact text matching for assertions
-
Test Structure for Service List
- Test 1: Page renders with correct heading and description
- Test 2: Table structure exists OR empty state is shown
- Test 3: Service row shows all status indicators (gracefully skips if no services)
- Test 4: Unfinalized service shows edit/finalize buttons (gracefully skips if no unfinalized services)
- Test 5: Finalized service shows reopen/download buttons (gracefully skips if no finalized services)
- Test 6: Status indicators display correct format patterns (gracefully skips if no services)
Files Created
tests/e2e/service-list.spec.ts— 6 E2E tests for service list page
Test Results
- 3 passed (page renders, table structure, empty state handling)
- 4 skipped (require services in test database)
- 0 failed
Critical Patterns
- ALWAYS use
await page.waitForLoadState('networkidle')after navigation - ALWAYS use data-testid selectors, never CSS selectors
- ALWAYS use German text from Vue components for assertions
- ALWAYS handle missing data gracefully with test.skip()
- NEVER assert specific CTS data values (service names, dates, counts)
[2026-03-01] Task 9: E2E Service Edit Information Block Tests
Key Learnings
-
Information Block Component Structure
- InformationBlock.vue wraps SlideUploader and SlideGrid components
- Uses data-testid="information-block" as root container
- SlideUploader has data-testid="information-block-uploader"
- SlideGrid has data-testid="information-block-grid"
- Expire date input: data-testid="slide-uploader-expire-input"
-
Accordion Toggle Pattern
- Block toggle buttons use data-testid="service-edit-block-toggle"
- Filter by text content to find specific block:
.filter({ has: page.locator('text=Information') }) - Transition duration is 300ms - use
page.waitForTimeout(300)after toggle - Block content is hidden with
v-show(not removed from DOM)
-
Slide Grid Selectors
- Delete buttons: data-testid="slide-grid-delete-button"
- Expire date inputs (edit mode): data-testid="slide-grid-expire-input"
- Save button: data-testid="slide-grid-expire-save"
- Cancel button: data-testid="slide-grid-expire-cancel"
- Full image link: data-testid="slide-grid-fullimage-link"
-
Graceful Test Degradation
- Tests must handle both cases: slides exist AND empty state
- Use
test.skip()when preconditions aren't met (no editable services, no slides) - Pattern: Check element visibility with
.isVisible().catch(() => false) - This allows tests to pass in any environment without hardcoding data
-
Datepicker Interaction
- Expire date field is a native HTML date input (type="date")
- Use
input.fill(dateString)where dateString is ISO format (YYYY-MM-DD) - Generate future dates:
new Date().toISOString().split('T')[0] - Verify value with
await input.inputValue()
-
Confirmation Dialog Pattern
- Delete confirmation uses text "Folie löschen?" as identifier
- Dialog contains "Möchtest du die Folie" and "wirklich löschen?"
- Cancel button text: "Abbrechen"
- Confirm button text: "Löschen"
- Use
page.locator('button:has-text("Abbrechen")').first()to find cancel button
-
German UI Text in Information Block
- Block header: "Informationsfolien"
- Description: "Globale Folien — sichtbar in allen Gottesdiensten bis zum Ablaufdatum"
- Expire date label: "Ablaufdatum für neue Folien"
- Dropzone text: "Dateien hier ablegen" and "oder klicken zum Auswählen"
- Empty state: "Noch keine Folien vorhanden"
- Delete confirmation: "Folie löschen?"
-
Test Structure for Information Block
- Test 1: Navigate to editable service edit page
- Test 2: Accordion is visible and can be expanded/collapsed
- Test 3: Upload area is visible with drag-drop zone
- Test 4: Expire date input is visible
- Test 5: Existing slides display as thumbnails (with empty state handling)
- Test 6: Datepicker is functional (skips if no slides)
- Test 7: Delete button triggers confirmation (skips if no slides)
Files Created
tests/e2e/service-edit-information.spec.ts— 7 E2E tests for Information block
Test Results
- 7 tests created (all gracefully skip when preconditions not met)
- Tests pass in any environment (with or without editable services/slides)
- 0 hardcoded IDs or data values
Critical Patterns
- ALWAYS use
await page.waitForLoadState('networkidle')after navigation - ALWAYS use data-testid selectors, never CSS selectors
- ALWAYS use German text from Vue components for assertions
- ALWAYS handle missing data gracefully with test.skip()
- ALWAYS wait for transitions with
page.waitForTimeout(300)after accordion toggle - NEVER assert specific slide content (dynamic CTS data)
- NEVER upload real files in tests (conversion tools not available)