|
1 | 1 | # E2E Test Engineer — Agent Memory (Index) |
2 | 2 |
|
3 | 3 | > Detailed notes live in topic files. This index links to them. |
4 | | -> See: `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-epic08-e2e.md`, `story-933-dav-vendor-contacts.md` |
| 4 | +> See: `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-epic08-e2e.md`, `story-933-dav-vendor-contacts.md`, `milestones-e2e.md`, `story-1248-mass-move.md` |
| 5 | +
|
| 6 | +## Print E2E Patterns (Issue #1310, 2026-04-19) |
| 7 | + |
| 8 | +- `page.emulateMedia({ media: 'print' })` makes CSS `@media print` rules apply without dispatching window events. |
| 9 | +- `usePrintExpansion` hook listens to `beforeprint`/`afterprint` — dispatch via `page.evaluate(() => window.dispatchEvent(new Event('beforeprint')))` BEFORE calling `emulateMedia`. |
| 10 | +- After dispatching `beforeprint`, React re-renders asynchronously. Use `page.waitForFunction(() => section.querySelector('[aria-expanded="true"]') !== null)` to wait for DOM update before asserting. |
| 11 | +- `breakdownAreaRow('Keller')` strict mode violation when Kellerbau is also in DOM: "Keller" is substring of "Kellerbau". Fix: `getByRole('row').filter({ has: page.locator('span', { hasText: /^Keller$/ }) })` for exact span match. |
| 12 | +- `getPropertyValue('--color-bg-primary').trim()` may return `'#ffffff'` OR `'rgb(255, 255, 255)'` depending on browser. Robust approach: create throwaway element, set `background-color: var(--my-var)`, read `getComputedStyle(el).backgroundColor` — always returns normalized `rgb()`. |
| 13 | +- `waitFor()` uses `actionTimeout` (5000ms for desktop). `expect().toBeVisible()` uses `expect.timeout` (7000ms for desktop). Use the latter for heading checks that may race with SPA init. |
| 14 | +- Desktop playwright project: `actionTimeout: 5000`, `expect.timeout: 7000`, `timeout: 15000`. |
| 15 | +- **afterprint state restore race**: if pre-print state already has some rows expanded, `waitForFunction('[aria-expanded="true"]')` resolves IMMEDIATELY (the element already exists), so `endPrint()` fires before full print expansion completes. Wait for a SPECIFIC element that was hidden before print to become visible (e.g., Kellerbau) before calling `endPrint()`. After `endPrint()`, use `waitFor({ state: 'hidden' })` for async restore. |
| 16 | +- **endPrint() must be in finally**: if test throws before `endPrint()`, print media leaks. Add `await endPrint().catch(() => {})` to `finally` block. `emulateMedia` is per-page so new pages get screen by default, but same-page tests in same worker can see leaked state. |
| 17 | +- **Playwright route glob `**/api/foo*`vs`/api/foo**`**: prefer `\*\*/api/foo*`(leading`**`) to match full URLs including `http://localhost:PORT/`prefix. The path-only form`/api/foo**` relies on baseURL prepending which can be unreliable. See diary-list.spec.ts pattern. |
| 18 | + |
| 19 | +## Stories #1271/#1272/#1273 E2E (2026-04-19) |
| 20 | + |
| 21 | +- Diary source entity breadcrumb: `PATCH /api/work-items/:id { status }` triggers auto diary entry. Find it via `GET /api/diary-entries?type=work_item_status&pageSize=50`, then filter by `sourceEntityId === workItemId`. |
| 22 | +- `AreaBreadcrumb` null area: renders `<span class*="muted">No area</span>` — NOT inside `[class*="compact"]`. Use `getByText('No area', { exact: true })` + `locator('[class*="compact"]').not.toBeVisible()`. |
| 23 | +- `InvoiceDetailPage` POM `budgetLinesSection` locator was wrong (`[class*="budgetLinesSection"]` doesn't exist). Fixed to `[aria-labelledby="budget-lines-title"]` (InvoiceBudgetLinesSection renders `<section aria-labelledby="budget-lines-title">`). |
| 24 | +- Invoice budget line creation: `POST /api/invoices/:invoiceId/budget-lines` (NOT `/api/vendors/:vendorId/invoices/:invoiceId/budget-lines`). |
| 25 | +- WI budget POST response: `{ budget: { id } }`. HI budget POST response: `{ budget: { id } }`. Invoice budget line POST: `{ budgetLine: { id } }`. |
| 26 | +- HI dependency creation: `POST /api/household-items/:id/dependencies { predecessorType, predecessorId }`. |
| 27 | +- HI dep list locator: `page.getByRole('list').filter({ has: page.locator('[class*="depRow"]') })` — only one list on the page. |
| 28 | +- Diary auto events enabled by default (`DIARY_AUTO_EVENTS=true`). No need to configure E2E container. |
| 29 | + |
| 30 | +## Budget Source Lines/Move + Work Item Create Regressions (fix/1279, 2026-04-18) |
| 31 | + |
| 32 | +- `getByText('Unassigned', { exact: true })` strict-mode violation: after PR #1265 made `isSelectable=true`, TriStateCheckbox renders `<span>Select all in Unassigned</span>` in the area group header. Playwright's `getByText` resolves to 2 elements (both the `<span>` AND the `areaName` span). Fix: use `panel.locator('[class*="areaName"]', { hasText: 'Unassigned' })`. |
| 33 | +- `checkbox.uncheck()` timeout: sticky `actionBar` (position:sticky; bottom:0) covers the checkbox on narrow viewports after Playwright's internal `scrollIntoViewIfNeeded()` positions the element under the bar. Fix: use `checkbox.click({ force: true })` to bypass coverage check. |
| 34 | +- `waitForURL('**/project/work-items/**')` resolves immediately on `/new` — glob `**` matches `new`. Fix: use UUID regex `waitForURL(/\/project\/work-items\/[0-9a-f]{8}-...-[0-9a-f]{12}$/)`. |
| 35 | + |
| 36 | +## Vendors to Settings Migration E2E (Story #1283, 2026-04-18) |
| 37 | + |
| 38 | +- Vendors moved from `/budget/vendors` to `/settings/vendors`; legacy redirects via React Router `<Navigate replace>` |
| 39 | +- `VENDORS_ROUTE` in VendorsPage POM = `/settings/vendors`; `ROUTES.budgetVendors` renamed to `ROUTES.settingsVendors` in testData.ts |
| 40 | +- `vendors.title` i18n key still = "Budget" — h1 heading on VendorsPage remains "Budget" (not "Vendors") |
| 41 | +- VendorsPage SubNav: `ariaLabel="Settings section navigation"` (was "Budget section navigation") |
| 42 | +- i18n.spec.ts German vendors test: updated SubNav aria-label + route constant |
| 43 | +- `e2e/tests/budget/vendors.spec.ts` deleted; moved to `e2e/tests/vendors/vendors.spec.ts` |
| 44 | +- Pre-existing CI failure on shard 5 (run 24531406436): milestones `getErrorBannerText()` returning null — not vendors-related |
| 45 | + |
| 46 | +## HI Breadcrumb E2E (Story #1240, 2026-04-17) |
| 47 | + |
| 48 | +- HouseholdItemDetailPage POM: `areaBreadcrumbNav` + `areaBreadcrumb` added (same pattern as WorkItemDetailPage) |
| 49 | +- HouseholdItemsPage list: name column renders `<div class*="titleCell">` → compact AreaBreadcrumb inside — `[class*="compact"]` selector |
| 50 | +- HouseholdItemDetailPage: default breadcrumb in `<div class*="titleBreadcrumb">` below h1 — `getByRole('navigation', { name: /area path/i })` |
| 51 | +- HouseholdItemPicker: `renderSecondary` renders compact breadcrumb in dropdown options — test via InvoiceDetailPage "Add Budget Line" modal |
| 52 | +- InvoiceDetailPage budget line modal: `getByRole('dialog', { name: 'Add Budget Line' })` → HI picker input `getByPlaceholder('Search household items...')` |
| 53 | +- Invoice route is `/budget/invoices/:id` (NOT `/project/budget/invoices/:id`) |
| 54 | +- Invoice API: `POST /api/vendors/:vendorId/invoices` (requires vendor first) → `{ invoice: { id } }` |
| 55 | +- **Invoice status enum**: valid values are `'pending'`, `'paid'`, `'claimed'`, `'quotation'` — NOT `'draft'`. Using `'draft'` causes 400 validation error. |
| 56 | +- `budget-source-lines.spec.ts` failures on feat/1239 branch: pre-existing, caused by `fix/source-lines-layout-links` feature not yet merged, not a breadcrumb regression |
| 57 | +- CI Shard 5 failure on beta release run (2026-04-16): from concurrent release workflow, not from feature work |
| 58 | + |
| 59 | +## Embeds/Pickers Breadcrumb E2E (Story #1239, 2026-04-16) |
| 60 | + |
| 61 | +- Gantt bar: `data-testid="gantt-bar-{id}"` on the SVG `<g>` element — use `page.getByTestId()` for hover |
| 62 | +- Gantt sidebar WI row: `data-testid="gantt-sidebar-row-{id}"` — `ganttSidebarRow(id)` helper added to TimelinePage POM |
| 63 | +- TimelinePage POM: `ganttBar(id)` helper added for bar hover tests |
| 64 | +- Milestone detail linked WI row: `[class*="linkedWorkItem"].filter({hasText:title})` — `linkedWorkItemRow(title)` helper added to MilestoneDetailPage POM |
| 65 | +- Link WI to milestone via API: `POST /api/milestones/:id/work-items` with `{ workItemId }` |
| 66 | +- GanttChart tooltip areaName: plain text string (not AreaBreadcrumb), joined with `›` — check `tooltip.textContent()` for area names |
| 67 | +- **Missing translation key**: `gantt.tooltip.workItem.areaLabel` is used in GanttTooltip.tsx but absent from `schedule.json` — i18next renders the key as fallback label text. Not a test issue; label text may show key string. Assert on the value (area path), not the label. |
| 68 | +- WorkItemPicker search results: `[role="option"]` buttons inside `getByRole('listbox')` — compact breadcrumb in `[class*="compact"]` inside option |
| 69 | +- Gantt sidebar + bar hover Gantt tests: skip on viewportWidth < 1200 (Gantt collapses on tablet/mobile) |
| 70 | +- WI create date pattern for Gantt visibility: `startDate=first of current month`, `endDate=last of 2 months ahead` |
| 71 | + |
| 72 | +## AreaBreadcrumb E2E Selectors (Story #1238, 2026-04-16) |
| 73 | + |
| 74 | +- compact variant: `[tabIndex="0"][class*="compact"]` — spans in list rows/cards |
| 75 | +- default variant: `getByRole('navigation', { name: /area path/i })` — in detail header & create preview |
| 76 | +- null area (both variants): `getByText('No area', { exact: true })` — span with class\*="muted" |
| 77 | +- Tooltip uses CSS opacity (0→1), so `toBeVisible()` works after `focus()` on the compact span |
| 78 | +- AreaPicker input: `getByPlaceholder('Select an area')` (i18n key common.aria.selectArea) |
| 79 | +- **CRITICAL**: `areaPickerInput` (placeholder locator) is ABSENT from DOM once an area is selected. |
| 80 | + SearchPicker replaces the `<input>` with a `selectedDisplay` chip + clear button. Never click/fill |
| 81 | + the input locator after selection. Use `getByRole('button', { name: 'Clear selection', exact: true })` |
| 82 | + to clear — this is `t('aria.clearSelection')` = "Clear selection". POM: `clearAreaPicker()` helper. |
| 83 | +- Listbox option: `getByRole('option', { name: /areaName/ })` inside `getByRole('listbox')` |
| 84 | +- "No area" special option in AreaPicker: `getByRole('option', { name: 'No area', exact: true })` |
| 85 | +- `createAreaViaApi` and `deleteAreaViaApi` already exist in `e2e/fixtures/apiHelpers.ts` |
| 86 | +- `areas` POST response shape: `{ area: { id: string } }` (confirmed from existing helper) |
| 87 | +- Milestones validation CI failure (2026-04-16): `milestones.spec.ts` scenarios 6+7 fail on beta/main |
| 88 | + promotion run — `getErrorBannerText()` returns null. Pre-existing on Dependabot bump commits. |
| 89 | + Not from feature work. Triage: pre-existing flaky/broken test on beta. |
| 90 | + |
| 91 | +## Invoices + Manage Settings E2E (2026-03-26) — Fixed 2026-03-26 |
| 92 | + |
| 93 | +POMs: `InvoicesPage.ts`, `InvoiceDetailPage.ts`, `HouseholdItemEditPage.ts`. |
| 94 | +Tests: `e2e/tests/invoices/invoices.spec.ts`, `e2e/tests/navigation/settings-manage.spec.ts`. |
| 95 | +Key API response shapes: Areas POST → `{area:{id}}`, Trades POST → `{trade:{id}}`, HI-Categories POST → `{id}` (entity directly), Invoices POST → `{invoice:{id}}`. |
| 96 | +InvoicesPage.heading = "Budget" (from PageLayout title=t('invoices.title')). Modal locator: `getByRole('dialog',{name:/Invoice/i})`. |
| 97 | +InvoiceDetailPage: edit modal `[role="dialog"][aria-labelledby="edit-modal-title"]`, delete modal `[aria-labelledby="delete-modal-title"]`, confirm delete button `[class*="confirmDeleteButton"]`. |
| 98 | +ManagePage tab panel IDs: `areas-panel`, `trades-panel`, `budget-categories-panel`, `hi-categories-panel`. Create form IDs: `#areaName`, `#tradeName`, `#categoryName` (same for budget AND hi-cat tabs — only one renders at a time). |
| 99 | +**ManagePage area/trade delete buttons have NO aria-label** — only text "Delete". Must scope via |
| 100 | +`panel.locator('[class*="itemRow"]').filter({ hasText: entityName }).getByRole('button', { name: 'Delete', exact: true })`. |
| 101 | +HI-categories delete buttons DO have `aria-label={Delete \${name}}` — getByRole with name works. |
| 102 | +InvoicesPage.waitForLoaded() uses Promise.any() (not Promise.race()) to avoid dangling rejections. |
| 103 | + |
| 104 | +## Milestones E2E (2026-03-26) — See milestones-e2e.md |
| 105 | + |
| 106 | +Heading="Project", newMilestone=testId("new-milestone-button"), search=client-side (no waitForResponse). |
| 107 | +List deleteModal=`getByRole('dialog',{name:'Delete Milestone'})`. Detail deleteModal=`[role="dialog"][aria-modal="true"]` (own impl). |
| 108 | +Milestone IDs are integers (not strings). Back/cancel on CreatePage are `<Link>` anchors, not buttons. |
5 | 109 |
|
6 | 110 | ## i18n German Locale: page.reload() Required After setLanguage() + page.goto() (2026-03-23) |
7 | 111 |
|
|
0 commit comments