From 4ac64bd634f9a2665394a25ad4f53da8090b9826 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Fri, 14 Aug 2026 16:01:07 +0200 Subject: [PATCH] fix(e2e): pick the seeded customer by name, not first option Scheduled CI run 31767912809 failed date-format.spec.ts:22 on all three attempts: the worklog customer pick takes the FIRST combobox option, but the option list is name-sorted and shared run-wide. A throwaway admin-spec customer (E2EInline_*, global, without projects) that leaked past its best-effort delete sorted before the seeded 'Freizeit' customer, so createWorklogEntry booked a customer without projects and the dependent project combobox rendered empty ("Keine Treffer") -- deterministically on every retry, because the leaked row persists in db-e2e for the rest of the shard. The identical failure already occurred in scheduled run 29629898230 (2026-07-18), a month before the composer.lock bump the failing SHA carries, which exonerates dependency PR #672. Reproduced locally at e5533522 by inserting a global, project-less 'E2EInline_*-draft' customer into db-e2e: byte-identical failure signature. With this fix the affected specs pass repeatedly against the still-polluted database. Fix: createWorklogEntry and the three blind ArrowDown/first-option customer picks in worklog-grid-editing.spec.ts filter the combobox to the seeded bookable customer (SEEDED_BOOKABLE_CUSTOMER = 'Freizeit') before picking. Project/activity stay first-option -- they are scoped to that customer and therefore deterministic. Claude-Session: https://claude.ai/code/session_01AcqcEjgwcQfp3vpnFa3gh6 Signed-off-by: Sebastian Mendel --- e2e/helpers/worklog.ts | 29 +++++++++++++++++++++++- e2e/worklog-grid-editing.spec.ts | 38 ++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/e2e/helpers/worklog.ts b/e2e/helpers/worklog.ts index b2f750861..8729e01c1 100644 --- a/e2e/helpers/worklog.ts +++ b/e2e/helpers/worklog.ts @@ -42,6 +42,31 @@ export async function pickFirstOption(page: Page, row: Locator, colKey: string, await expect(page.locator('.combobox-content')).toBeHidden({ timeout: 4000 }); } +/** + * The one seeded customer both e2e users can book (`Freizeit`, id 2 — global, + * with projects; see e2e/AGENTS.md and sql/testdata.sql). The worklog CUSTOMER + * pick must target it BY NAME, never "first option": the option list is + * name-sorted and shared run-wide, so a throwaway admin-spec customer + * (`E2E*`, global, without projects) that leaks past its best-effort delete + * sorts first — a blind first-option pick then selects it and the dependent + * project combobox renders empty ("Keine Treffer"), deterministically failing + * every retry (scheduled runs 29629898230 and 31767912809, shard 1). + */ +export const SEEDED_BOOKABLE_CUSTOMER = 'Freizeit'; + +/** Open a relation cell's combobox, filter it by `text`, and pick the first match. */ +export async function pickOptionByText(page: Page, row: Locator, colKey: string, text: string): Promise { + await row.locator(`td[data-col-key="${colKey}"]`).focus(); + await page.keyboard.press('Enter'); + const input = page.locator('.combobox-input').first(); + await expect(input).toBeVisible(); + await input.fill(text); + const option = page.locator('.combobox-content .combobox-item').filter({ hasText: text }).first(); + await expect(option).toBeVisible({ timeout: 8000 }); + await option.click(); + await expect(page.locator('.combobox-content')).toBeHidden({ timeout: 4000 }); +} + export function rowByStamp(page: Page, stamp: string): Locator { return page.locator('tr.tracking-row').filter({ hasText: stamp }).first(); } @@ -123,7 +148,9 @@ export async function createWorklogEntry(page: Page): Promise { // late-landing refetch (the historic boundingBox-null flake). const saved = page.waitForResponse(isSaveResponse); const refetched = page.waitForResponse(isEntriesRefetch); - await pickFirstOption(page, row, 'customer'); + // Customer by NAME (leaked E2E* customers sort first — see SEEDED_BOOKABLE_CUSTOMER); + // project/activity are scoped to that customer, so first-option is deterministic. + await pickOptionByText(page, row, 'customer', SEEDED_BOOKABLE_CUSTOMER); await pickFirstOption(page, row, 'project'); await pickFirstOption(page, row, 'activity'); await saved; diff --git a/e2e/worklog-grid-editing.spec.ts b/e2e/worklog-grid-editing.spec.ts index 8ed1fd352..2610c44d4 100644 --- a/e2e/worklog-grid-editing.spec.ts +++ b/e2e/worklog-grid-editing.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; import { loginIsolated } from './helpers/auth'; import { installFrozenClock } from './helpers/clock'; import { goToWorklogPage } from './helpers/navigation'; -import { cleanupWorklogEntries, createWorklogEntry, isSaveResponse, rowByStamp } from './helpers/worklog'; +import { SEEDED_BOOKABLE_CUSTOMER, cleanupWorklogEntries, createWorklogEntry, isSaveResponse, rowByStamp } from './helpers/worklog'; /** * Spreadsheet-style keyboard + clipboard editing on the SolidJS worklog grid: @@ -136,8 +136,17 @@ test.describe('Worklog grid — keyboard & clipboard editing', () => { await page.keyboard.press('Tab'); await expect(row.locator('td[data-col-key="customer"][data-inline-editing]')).toBeVisible(); - const arrowEnter = async (): Promise => { - await expect(page.locator('.combobox-input').first()).toBeVisible(); + const arrowEnter = async (filter?: string): Promise => { + const input = page.locator('.combobox-input').first(); + await expect(input).toBeVisible(); + // The customer step filters to the seeded bookable customer first — the raw + // list is name-sorted and a leaked E2E* throwaway customer (no projects) + // would sort first, so a blind ArrowDown would pick it and leave the + // subsequent project list empty (see SEEDED_BOOKABLE_CUSTOMER). + if (filter !== undefined) { + await input.fill(filter); + await expect(page.locator('.combobox-content .combobox-item').first()).toContainText(filter, { timeout: 8000 }); + } // Wait for the option list to populate before navigating it — under shard load // ArrowDown can fire before the (async) options arrive, highlighting nothing, so // Enter then picks nothing and the guide never advances to the next field. @@ -148,7 +157,7 @@ test.describe('Worklog grid — keyboard & clipboard editing', () => { // customer/project/activity are always required-and-empty for a new row, so the // guide jumps through them. (date/start/end are pre-filled by suggest-time + // the end-prefill minimum, so they're skipped — the guide only targets empties.) - await arrowEnter(); + await arrowEnter(SEEDED_BOOKABLE_CUSTOMER); await expect(row.locator('td[data-col-key="project"][data-inline-editing]')).toBeVisible(); await arrowEnter(); await expect(row.locator('td[data-col-key="activity"][data-inline-editing]')).toBeVisible(); @@ -176,14 +185,21 @@ test.describe('Worklog grid — keyboard & clipboard editing', () => { await page.keyboard.press('Tab'); await expect(page.locator('td[data-col-key="customer"][data-inline-editing]')).toBeVisible(); - const arrowEnter = async (): Promise => { - await expect(page.locator('.combobox-input').first()).toBeVisible(); + const arrowEnter = async (filter?: string): Promise => { + const input = page.locator('.combobox-input').first(); + await expect(input).toBeVisible(); + // Filter the customer step to the seeded bookable customer (see the + // guided-flow test above and SEEDED_BOOKABLE_CUSTOMER). + if (filter !== undefined) { + await input.fill(filter); + await expect(page.locator('.combobox-content .combobox-item').first()).toContainText(filter, { timeout: 8000 }); + } // Wait for the options before arrowing (see the guided-flow test above). await expect(page.locator('.combobox-content .combobox-item').first()).toBeVisible({ timeout: 8000 }); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); }; - await arrowEnter(); // customer — guided on to project + await arrowEnter(SEEDED_BOOKABLE_CUSTOMER); // customer — guided on to project await expect(page.locator('td[data-col-key="project"][data-inline-editing]')).toBeVisible(); await arrowEnter(); // project — guided on to activity await expect(page.locator('td[data-col-key="activity"][data-inline-editing]')).toBeVisible(); @@ -223,6 +239,14 @@ test.describe('Worklog grid — keyboard & clipboard editing', () => { await row.locator('td[data-col-key="customer"]').focus(); await page.keyboard.press('Enter'); // open the (portalled) select editor + // Filter to the seeded bookable customer before arrowing — a blind ArrowDown + // would pick whatever sorts first, and a leaked E2E* throwaway customer (no + // projects) would invalidate the row and re-render it, losing the very focus + // this test asserts on (see SEEDED_BOOKABLE_CUSTOMER). + const input = page.locator('.combobox-input').first(); + await expect(input).toBeVisible(); + await input.fill(SEEDED_BOOKABLE_CUSTOMER); + await expect(page.locator('.combobox-content .combobox-item').first()).toContainText(SEEDED_BOOKABLE_CUSTOMER, { timeout: 8000 }); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); // commit — focus must return to a grid cell, not await expect(page.locator('.tracking-table td:focus')).toHaveCount(1);