Skip to content

Commit dd1862d

Browse files
authored
Merge pull request #1332 from steilerDev/beta
release: promote beta to main
2 parents 0eb47cc + f3c8603 commit dd1862d

494 files changed

Lines changed: 53141 additions & 6982 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/agent-memory/e2e-test-engineer/MEMORY.md

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,111 @@
11
# E2E Test Engineer — Agent Memory (Index)
22

33
> 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.
5109

6110
## i18n German Locale: page.reload() Required After setLanguage() + page.goto() (2026-03-23)
7111

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
name: milestones-e2e
3+
description: Key POM selectors and patterns for the milestones E2E tests written in 2026-03
4+
type: project
5+
---
6+
7+
# Milestones E2E — Selectors and Patterns
8+
9+
**Files**: `e2e/pages/MilestonesPage.ts`, `e2e/pages/MilestoneCreatePage.ts`, `e2e/pages/MilestoneDetailPage.ts`, `e2e/tests/milestones/milestones.spec.ts`
10+
11+
## Key Selectors
12+
13+
### MilestonesPage (list)
14+
15+
- Heading: `getByRole('heading', { level: 1, name: 'Project' })` — PageLayout h1 = "Project" (milestones.page.title)
16+
- New Milestone: `getByTestId('new-milestone-button')` — stable data-testid on the button
17+
- Search: `getByLabel('Search items')` — client-side filtering, NO waitForResponse needed after fill()
18+
- Delete modal: `getByRole('dialog', { name: 'Delete Milestone' })` — shared Modal component
19+
- Confirm delete: `[class*="btnConfirmDelete"]` inside delete modal
20+
- Actions menu: `[aria-label="Actions menu"]` in table row/card
21+
22+
### MilestoneCreatePage
23+
24+
- Page h1: `getByRole('heading', { level: 1, name: 'Project' })` — same as list (PageLayout)
25+
- Form h2: `getByRole('heading', { level: 2, name: 'Create Milestone' })`
26+
- Back link: `getByRole('link', { name: '← Milestones' })` — it's an <a>, NOT <button>
27+
- Cancel: `getByRole('link', { name: 'Cancel' })` — also a <Link> (<a>), NOT <button>
28+
- Title: `locator('#title')` or `getByTestId('milestone-title-input')`
29+
- Target date: `locator('#targetDate')` or `getByTestId('milestone-target-date-input')`
30+
- Submit: `getByTestId('create-milestone-button')`
31+
- Error banner: `locator('[role="alert"][class*="errorBanner"]')` — used for BOTH validation + server errors
32+
33+
### MilestoneDetailPage
34+
35+
- h1: `getByRole('heading', { level: 1 })` — the milestone title (dynamic)
36+
- Back button: `getByRole('button', { name: /← Back to Milestones/i })` — <button> NOT <a>
37+
- Edit button: `getByTestId('edit-milestone-button')`
38+
- Delete button: `getByTestId('delete-milestone-button')`
39+
- Status badge: `locator('[class*="statusBadge"]')` — text "Completed" or "Pending"
40+
- Save button: `getByTestId('save-milestone-button')`
41+
- Completed checkbox: `getByTestId('milestone-completed-checkbox')`
42+
- Delete modal: `locator('[role="dialog"][aria-modal="true"]')` — own implementation (NOT shared Modal)
43+
- Confirm delete: `getByTestId('confirm-delete-milestone')`
44+
- Not found state: `locator('[class*="notFound"]')`
45+
46+
## Critical Behavioral Notes
47+
48+
1. **MilestonesPage search is client-side**: All milestones are loaded once at mount. The DataTable
49+
filters them synchronously on search input change. No API waitForResponse after fill().
50+
However, `waitForLoaded()` after `goto()` is still needed to wait for the initial GET.
51+
52+
2. **List page heading = "Project"**: Both MilestonesPage and WorkItemsPage use PageLayout with
53+
`title={t('milestones.page.title')}` = "Project" — the SubNav distinguishes which tab is active.
54+
55+
3. **getMilestoneTitles() fallback**: No `[class*="itemLink"]` or `[class*="vendorLink"]` in
56+
milestones — the title column renders plain text. Read first `td` of each row (desktop) or
57+
first `cardCell` (mobile).
58+
59+
4. **Detail page uses its own modal, not shared Modal**: The MilestoneDetailPage delete modal is
60+
a custom `<div role="dialog" aria-modal="true">`. The MilestonesPage list delete modal uses
61+
the shared `<Modal>` component. Selectors differ between the two pages.
62+
63+
5. **createMilestoneViaApi returns number**: Unlike work items (string UUID), milestone IDs are
64+
integers. Use `createdId: number | null` in tests.
65+
66+
6. **POST /api/milestones response shape — NO wrapper**: The server returns `MilestoneDetail`
67+
directly (e.g. `{ id: 1, title: "...", ... }`), NOT wrapped in `{ milestone: { id: 1 } }`.
68+
This was a bug in the original test/helper code — `body.milestone.id` caused
69+
`TypeError: Cannot read properties of undefined (reading 'id')` in CI. Correct pattern:
70+
`const body = (await response.json()) as { id: number }; return body.id;`
71+
Same applies to in-test POST response parsing in Scenarios 4 and 5.
72+
73+
**Why**: Milestones feature had zero E2E coverage. Written as part of Gap-1 E2E work (2026-03).
74+
**How to apply**: When adding more milestone-related tests, reuse these POMs and patterns.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
name: Story #1248 Mass-Move E2E Patterns
3+
description: Key selectors and patterns for BudgetSources multi-select + mass-move dialog E2E tests
4+
type: project
5+
---
6+
7+
# Story #1248 — Multi-select + Mass-Move Dialog E2E
8+
9+
**Files**: `e2e/tests/budget/budget-source-move.spec.ts` (NEW, 8 tests), `e2e/pages/BudgetSourcesPage.ts` (modified).
10+
11+
## Key Selectors
12+
13+
- **Per-line checkbox**: `getByRole('checkbox', { name: 'Select {description}' })` scoped to lines panel
14+
- **Area group TriStateCheckbox**: `getByRole('checkbox', { name: 'Select all in {areaName}' })` scoped to panel
15+
- **Action bar**: `getLinesPanelById(id).locator('[class*="actionBar"]')` — only renders when ≥1 line selected
16+
- **Move button**: `getActionBar(id).getByRole('button', { name: 'Move to another source\u2026' })`
17+
- **Move modal**: `getByRole('dialog', { name: 'Move lines to another source' })` — Modal uses `useId()` for aria-labelledby, so name-based matching is required
18+
- **SearchPicker input**: `moveModal.locator('#target-source')` — fixed ID passed as `id="target-source"` prop
19+
- **Picker options**: `moveModal.getByRole('option', { name: sourceName })` — SearchPicker renders `role="option"` buttons
20+
- **Confirm button**: `moveModal.getByRole('button', { name: /Move lines|Loading/i })` — loading state shows "Loading"
21+
- **Warning block**: `moveModal.locator('[role="alert"]')` — rendered only when claimedCount > 0
22+
- **FormError banner**: `moveModal.locator('[class*="banner"]')` — FormError uses CSS module `.banner` class with `role="alert"`; distinguish from warning block via CSS class not role
23+
- **Understood checkbox**: `getByRole('checkbox', { name: 'I understand this will reassign lines with a claimed invoice' })`
24+
25+
## API URLs for Mocking
26+
27+
- Lines: `**/api/budget-sources/${sourceId}/budget-lines`
28+
- Move PATCH: `**/api/budget-sources/${sourceId}/budget-lines/move`
29+
- Move success response: `{ movedWorkItemLines: N, movedHouseholdItemLines: N }`
30+
- Move 409: `{ error: { code: 'STALE_OWNERSHIP', message: '...' } }`
31+
32+
## Implementation Notes
33+
34+
- `MassMoveModal.handleSearchSources` filters sources client-side (`s.id !== sourceId`) — no server-side exclude query param
35+
- `handleSelectTarget` only sets `targetSourceName`; `setTargetSourceId` is passed as `onChange` to SearchPicker directly
36+
- `canConfirm = targetSourceId !== '' && (claimedCount === 0 || understood) && !isSubmitting`
37+
- Toast on success: `role="alert"` from Toast component; text: "Moved N line(s) to {targetName}"
38+
- The action bar renders INSIDE the lines panel (`source-lines-{id}`) — scope to panel, not page
39+
40+
## Why:
41+
42+
This was Story #1248 — the frontend multi-select + mass-move feature for budget source lines. Patterns apply to any future modal that uses the shared `Modal` + `SearchPicker` combo.

0 commit comments

Comments
 (0)