From ceb58c6a98bec900fe6809e29193ab71dbbce44d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 11:46:23 +0100 Subject: [PATCH 1/9] Fix OTP typing reliability in Firefox and WebKit during parallel test execution --- .../WebApp/tests/e2e/global-ui-flows.spec.ts | 22 ++------ .../tests/e2e/localization-flows.spec.ts | 17 +++--- .../WebApp/tests/e2e/login-flows.spec.ts | 33 ++++++------ .../e2e/permission-based-ui-flows.spec.ts | 8 ++- .../e2e/session-management-flows.spec.ts | 6 +-- .../WebApp/tests/e2e/signup-flows.spec.ts | 9 ++-- .../tests/e2e/tenant-switching-flows.spec.ts | 14 ++--- .../tests/e2e/utils/test-assertions.ts | 54 ++++++++++++++++++- .../tests/e2e/utils/test-data.ts | 8 +-- 9 files changed, 99 insertions(+), 72 deletions(-) diff --git a/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts b/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts index 3a556401b..3561253c6 100644 --- a/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/global-ui-flows.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; -import { createTestContext } from "@shared/e2e/utils/test-assertions"; +import { createTestContext, typeOneTimeCode } from "@shared/e2e/utils/test-assertions"; import { getVerificationCode } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -204,16 +204,8 @@ test.describe("@comprehensive", () => { await page.getByRole("button", { name: "Continue" }).click(); await expect(page).toHaveURL("/login/verify"); + await typeOneTimeCode(page, getVerificationCode()); - // Wait for verification input to be ready - const verificationInput = page.locator('input[autocomplete="one-time-code"]').first(); - await expect(verificationInput).toBeVisible(); - await verificationInput.focus(); - - // Auto-submits on first login - await page.keyboard.type(getVerificationCode()); - - // Wait for auto-submit to complete await expect(page).toHaveURL("/admin"); await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); })(); @@ -255,16 +247,8 @@ test.describe("@comprehensive", () => { await page.getByRole("button", { name: "Continue" }).click(); await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); + await typeOneTimeCode(page, getVerificationCode()); - // Wait for verification input to be ready - const verificationInput = page.locator('input[autocomplete="one-time-code"]').first(); - await expect(verificationInput).toBeVisible(); - await verificationInput.focus(); - - // Auto-submits on first login - await page.keyboard.type(getVerificationCode()); - - // Wait for auto-submit to complete await expect(page).toHaveURL("/admin"); await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); diff --git a/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts index 6bc96dd5d..ccef86c3f 100644 --- a/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; -import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { createTestContext, expectToastMessage, typeOneTimeCode } from "@shared/e2e/utils/test-assertions"; import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -34,8 +34,7 @@ test.describe("@comprehensive", () => { })(); await step("Complete verification with Danish interface & verify navigation to admin")(async () => { - // Auto-submits on 6 characters - await page.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page, getVerificationCode()); await expect(page).toHaveURL("/admin"); })(); @@ -82,8 +81,7 @@ test.describe("@comprehensive", () => { })(); await step("Complete login verification & verify language resets to user's saved preference")(async () => { - // Auto-submits on 6 characters - await page.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page, getVerificationCode()); await expect(page).toHaveURL("/admin"); await expect(page.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); @@ -124,8 +122,7 @@ test.describe("@comprehensive", () => { await page1.getByRole("button", { name: "Opret din konto" }).click(); await expect(page1).toHaveURL("/signup/verify"); - // Auto-submits on 6 characters - await page1.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page1, getVerificationCode()); // Complete profile in Danish await page1.getByRole("textbox", { name: "Fornavn" }).fill(user1.firstName); @@ -157,8 +154,7 @@ test.describe("@comprehensive", () => { await newPage1.getByRole("button", { name: "Continue" }).click(); await expect(newPage1).toHaveURL("/login/verify"); - // Auto-submits on 6 characters - await newPage1.keyboard.type(getVerificationCode()); + await typeOneTimeCode(newPage1, getVerificationCode()); // Verify Danish preference is restored after login await expect(newPage1).toHaveURL("/admin"); @@ -176,8 +172,7 @@ test.describe("@comprehensive", () => { await newPage2.getByRole("button", { name: "Continue" }).click(); await expect(newPage2).toHaveURL("/login/verify"); - // Auto-submits on 6 characters - await newPage2.keyboard.type(getVerificationCode()); + await typeOneTimeCode(newPage2, getVerificationCode()); // Verify English preference is maintained await expect(newPage2).toHaveURL("/admin"); diff --git a/application/account-management/WebApp/tests/e2e/login-flows.spec.ts b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts index 1c289d542..d44e0a41b 100644 --- a/application/account-management/WebApp/tests/e2e/login-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts @@ -1,6 +1,11 @@ import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; -import { blurActiveElement, createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { + blurActiveElement, + createTestContext, + expectToastMessage, + typeOneTimeCode +} from "@shared/e2e/utils/test-assertions"; import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -37,17 +42,15 @@ test.describe("@smoke", () => { })(); await step("Enter wrong verification code & verify error and focus reset")(async () => { - await page.keyboard.type("WRONG1"); // The verification code auto submits the first time + await typeOneTimeCode(page, "WRONG1"); await expectToastMessage(context, 400, "The code is wrong or no longer valid."); await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); })(); await step("Complete successful login & verify navigation to admin")(async () => { - // Re-enter correct code (manual submit required after failed attempt) - await page.locator('input[autocomplete="one-time-code"]').first().focus(); - await page.keyboard.type(getVerificationCode()); // The verification does not auto submit the second time - await page.getByRole("button", { name: "Verify" }).click(); + await typeOneTimeCode(page, getVerificationCode()); + await page.getByRole("button", { name: "Verify" }).click(); // Auto-submit only happens when entering the first OTP // Verify successful login await expect(page).toHaveURL("/admin"); @@ -89,9 +92,7 @@ test.describe("@smoke", () => { await page.getByRole("button", { name: "Continue" }).click(); await expect(page).toHaveURL("/login/verify"); - await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); - - await page.keyboard.type(getVerificationCode()); // The verification code auto submits + await typeOneTimeCode(page, getVerificationCode()); await expect(page).toHaveURL("/admin"); })(); @@ -119,31 +120,31 @@ test.describe("@comprehensive", () => { })(); await step("Enter first wrong code & verify error and focus reset")(async () => { - await page.keyboard.type("WRONG1"); // The verification code auto submits the first time + await typeOneTimeCode(page, "WRONG1"); await expectToastMessage(context, 400, "The code is wrong or no longer valid."); await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); })(); await step("Enter second wrong code & verify error and focus reset")(async () => { - await page.keyboard.type("WRONG2"); - await page.getByRole("button", { name: "Verify" }).click(); + await typeOneTimeCode(page, "WRONG2"); + await page.getByRole("button", { name: "Verify" }).click(); // Auto-submit only happens when entering the first OTP await expectToastMessage(context, 400, "The code is wrong or no longer valid."); await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); })(); await step("Enter third wrong code & verify error and focus reset")(async () => { - await page.keyboard.type("WRONG3"); - await page.getByRole("button", { name: "Verify" }).click(); + await typeOneTimeCode(page, "WRONG3"); + await page.getByRole("button", { name: "Verify" }).click(); // Auto-submit only happens when entering the first OTP await expectToastMessage(context, 400, "The code is wrong or no longer valid."); await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); })(); await step("Enter fourth wrong code & verify rate limiting triggers")(async () => { - await page.keyboard.type("WRONG4"); - await page.getByRole("button", { name: "Verify" }).click(); + await typeOneTimeCode(page, "WRONG4"); + await page.getByRole("button", { name: "Verify" }).click(); // Auto-submit only happens when entering the first OTP // Verify rate limiting is enforced await expect(page.getByText("Too many attempts, please request a new code.").first()).toBeVisible(); diff --git a/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts index d1464ef99..b2d9d3641 100644 --- a/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; -import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { createTestContext, expectToastMessage, typeOneTimeCode } from "@shared/e2e/utils/test-assertions"; import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -139,9 +139,8 @@ test.describe("@smoke", () => { await page.getByRole("textbox", { name: "Email" }).fill(member.email); await page.getByRole("button", { name: "Continue" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page, getVerificationCode()); - // Wait for navigation to complete after verification await page.waitForURL("/admin"); })(); @@ -347,9 +346,8 @@ test.describe("@smoke", () => { await page.getByRole("textbox", { name: "Email" }).fill(member.email); await page.getByRole("button", { name: "Continue" }).click(); await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page, getVerificationCode()); - // Wait for navigation to complete after verification await page.waitForURL("/admin"); })(); diff --git a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts index dc9fcd983..46b2bbc7f 100644 --- a/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/session-management-flows.spec.ts @@ -1,7 +1,7 @@ import type { Browser, BrowserContext, Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; -import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { createTestContext, expectToastMessage, typeOneTimeCode } from "@shared/e2e/utils/test-assertions"; import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -95,7 +95,7 @@ test.describe("@smoke", () => { await secondPage.getByRole("textbox", { name: "Email" }).fill(owner.email); await secondPage.getByRole("button", { name: "Continue" }).click(); await expect(secondPage).toHaveURL("/login/verify"); - await secondPage.keyboard.type(getVerificationCode()); + await typeOneTimeCode(secondPage, getVerificationCode()); await expect(secondPage).toHaveURL("/admin"); await expect(secondPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); @@ -195,7 +195,7 @@ test.describe("@comprehensive", () => { await secondPage.getByRole("textbox", { name: "Email" }).fill(owner.email); await secondPage.getByRole("button", { name: "Continue" }).click(); await expect(secondPage).toHaveURL("/login/verify"); - await secondPage.keyboard.type(getVerificationCode()); + await typeOneTimeCode(secondPage, getVerificationCode()); await expect(secondPage).toHaveURL("/admin"); await expect(secondPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); diff --git a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts index 3e8425d92..1d1e36699 100644 --- a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts @@ -4,7 +4,8 @@ import { blurActiveElement, createTestContext, expectToastMessage, - expectValidationError + expectValidationError, + typeOneTimeCode } from "@shared/e2e/utils/test-assertions"; import { getVerificationCode, testUser, uniqueEmail } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -76,20 +77,20 @@ test.describe("@smoke", () => { // === VERIFICATION CODE VALIDATION === await step("Enter wrong verification code & verify error and focus reset")(async () => { - await page.keyboard.type("WRONG1"); // Auto-submits on 6 characters + await typeOneTimeCode(page, "WRONG1"); await expectToastMessage(testContext, 400, "The code is wrong or no longer valid."); await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); })(); await step("Type verification code & verify submit button enables")(async () => { - await page.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page, getVerificationCode()); await expect(page.getByRole("button", { name: "Verify" })).toBeEnabled(); })(); await step("Click verify button & verify navigation to admin with profile dialog")(async () => { - await page.getByRole("button", { name: "Verify" }).click(); + await page.getByRole("button", { name: "Verify" }).click(); // Auto-submit only happens when entering the first OTP await expect(page).toHaveURL("/admin"); await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); diff --git a/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts b/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts index 0d5c2bfc7..0406eabf7 100644 --- a/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/tenant-switching-flows.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; -import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { createTestContext, expectToastMessage, typeOneTimeCode } from "@shared/e2e/utils/test-assertions"; import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -141,7 +141,7 @@ test.describe("@comprehensive", () => { await page1.getByRole("textbox", { name: "Email" }).fill(user.email); await page1.getByRole("button", { name: "Continue" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page1.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page1, getVerificationCode()); // Wait for navigation to complete - could be Users or Home page await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); await expect(page1).toHaveURL("/admin/users"); @@ -257,7 +257,7 @@ test.describe("@comprehensive", () => { await page1.getByRole("textbox", { name: "Email" }).fill(user.email); await page1.getByRole("button", { name: "Continue" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page1.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page1, getVerificationCode()); // Wait for navigation to complete await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); @@ -343,7 +343,7 @@ test.describe("@comprehensive", () => { await page1.getByRole("textbox", { name: "Email" }).fill(secondUser.email); await page1.getByRole("button", { name: "Continue" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page1.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page1, getVerificationCode()); await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); })(); @@ -372,7 +372,7 @@ test.describe("@comprehensive", () => { await page1.getByRole("textbox", { name: "Email" }).fill(user.email); await page1.getByRole("button", { name: "Continue" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page1.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page1, getVerificationCode()); await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); // Navigate page2 to admin @@ -403,7 +403,7 @@ test.describe("@comprehensive", () => { await page1.getByRole("textbox", { name: "Email" }).fill(user.email); await page1.getByRole("button", { name: "Continue" }).click(); await expect(page1.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page1.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page1, getVerificationCode()); await expect(page1.locator('nav[aria-label="Main navigation"]')).toBeVisible(); // Tab 1 should login to secondary tenant (last selected) @@ -486,7 +486,7 @@ test.describe("@comprehensive", () => { await page2.getByRole("textbox", { name: "Email" }).fill(user.email); await page2.getByRole("button", { name: "Continue" }).click(); await expect(page2.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); - await page2.keyboard.type(getVerificationCode()); + await typeOneTimeCode(page2, getVerificationCode()); await expect(page2.locator('nav[aria-label="Main navigation"]')).toBeVisible(); // Open tenant selector in page2 (should be on primary tenant after login) diff --git a/application/shared-webapp/tests/e2e/utils/test-assertions.ts b/application/shared-webapp/tests/e2e/utils/test-assertions.ts index 60d0673b4..2b9e60294 100644 --- a/application/shared-webapp/tests/e2e/utils/test-assertions.ts +++ b/application/shared-webapp/tests/e2e/utils/test-assertions.ts @@ -73,7 +73,8 @@ function startMonitoring(page: Page): MonitoringResults { "Loading CSS chunk", // CSS chunk loading failures in dev environment "Error: Loading CSS chunk", // CSS chunk loading error variations "downloadable font: download failed", // Firefox font loading failures - "downloadable font: glyf:" // Firefox font loading failures + "downloadable font: glyf:", // Firefox font loading failures + "ResizeObserver loop completed with undelivered notifications" // Benign browser warning, especially in Firefox ]; const isExpected = expectedMessages.some((expected) => message.includes(expected)); @@ -458,3 +459,54 @@ export async function blurActiveElement(page: Page): Promise { } }); } + +/** + * Type an OTP verification code into the one-time-code inputs. + * + * This function dispatches keyboard events directly via page.evaluate() for maximum + * reliability under parallel test execution in Firefox where Playwright's keyboard + * API can drop keystrokes: + * - Events are dispatched synchronously in the browser context + * - The OTP component (Digit.tsx) uses onKeyUp to capture characters and advance focus + * + * A microtask yield is included after each character to allow React's state updates + * to propagate and advance focus to the next input. This is critical for WebKit + * where the event loop timing differs from Chromium and Firefox. + * + * Note: We don't verify values after typing because auto-submit may navigate away + * before verification completes. Tests verify success via navigation or error toasts. + * + * @param page The Playwright page instance + * @param code The verification code to enter (e.g., "UNLOCK", "WRONG1") + */ +export async function typeOneTimeCode(page: Page, code: string): Promise { + const otpInputs = page.locator('input[autocomplete="one-time-code"]'); + + // Wait for the first OTP input to be focused before typing + await expect(otpInputs.first()).toBeFocused(); + + // Dispatch keyboard events directly for each character + for (const char of code) { + await page.evaluate(async (key) => { + const activeElement = document.activeElement as HTMLInputElement; + if (!activeElement) return; + + // Dispatch keydown + activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key, code: `Key${key}`, bubbles: true, cancelable: true }) + ); + + // Set value and dispatch input event + activeElement.value = key; + activeElement.dispatchEvent(new InputEvent("input", { bubbles: true, data: key })); + + // Dispatch keyup (triggers OTP component's onKeyUp handler) + activeElement.dispatchEvent( + new KeyboardEvent("keyup", { key, code: `Key${key}`, bubbles: true, cancelable: true }) + ); + + // Yield to microtask queue to allow React state updates and focus advancement + await new Promise((resolve) => setTimeout(resolve, 0)); + }, char); + } +} diff --git a/application/shared-webapp/tests/e2e/utils/test-data.ts b/application/shared-webapp/tests/e2e/utils/test-data.ts index f3e5cbe0c..bb490b3cd 100644 --- a/application/shared-webapp/tests/e2e/utils/test-data.ts +++ b/application/shared-webapp/tests/e2e/utils/test-data.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { isLocalhost } from "./constants"; import type { TestContext } from "./test-assertions"; -import { expectToastMessage } from "./test-assertions"; +import { expectToastMessage, typeOneTimeCode } from "./test-assertions"; /** * Read platform settings from the shared-kernel JSONC file. @@ -151,11 +151,7 @@ export async function completeSignupFlow( await expect(page).toHaveURL("/signup/verify"); // Step 3: Enter verification code (auto-submits after 6 characters) - // Wait for the first input to be focused before typing - await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); - await page.keyboard.type(getVerificationCode()); - - // Wait for successful signup - the form auto-submits and navigates to /admin + await typeOneTimeCode(page, getVerificationCode()); await expect(page).toHaveURL("/admin"); await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); From 6445418bbb8c4f8b9ecf22654e30569fce49724c Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 12:21:39 +0100 Subject: [PATCH 2/9] Add browser-specific auth state paths to fix cross-browser test isolation --- .../tests/e2e/auth/auth-state-manager.ts | 15 +++++++++++---- .../shared-webapp/tests/e2e/auth/storage-state.ts | 11 ++++++++--- .../shared-webapp/tests/e2e/fixtures/page-auth.ts | 9 ++++++++- .../tests/e2e/fixtures/worker-auth.ts | 5 ----- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts index 1c977e85b..89856e7fa 100644 --- a/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts +++ b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts @@ -13,10 +13,12 @@ import type { UserRole } from "@shared/e2e/types/auth"; */ export class AuthStateManager { private readonly workerIndex: number; + private readonly browserName: string; private readonly selfContainedSystemPrefix?: string; - constructor(workerIndex: number, selfContainedSystemPrefix?: string) { + constructor(workerIndex: number, browserName: string, selfContainedSystemPrefix?: string) { this.workerIndex = workerIndex; + this.browserName = browserName; this.selfContainedSystemPrefix = selfContainedSystemPrefix; } @@ -26,7 +28,7 @@ export class AuthStateManager { * @returns Path to the storage state file */ getStateFilePath(role: UserRole): string { - return getStorageStatePath(this.workerIndex, role.toLowerCase(), this.selfContainedSystemPrefix); + return getStorageStatePath(this.workerIndex, role.toLowerCase(), this.browserName, this.selfContainedSystemPrefix); } /** @@ -98,9 +100,14 @@ export class AuthStateManager { /** * Create an AuthStateManager instance for the current worker * @param workerIndex Playwright worker index + * @param browserName Browser name (e.g., "chromium", "firefox", "webkit") * @param selfContainedSystemPrefix Optional system prefix * @returns AuthStateManager instance */ -export function createAuthStateManager(workerIndex: number, selfContainedSystemPrefix?: string): AuthStateManager { - return new AuthStateManager(workerIndex, selfContainedSystemPrefix); +export function createAuthStateManager( + workerIndex: number, + browserName: string, + selfContainedSystemPrefix?: string +): AuthStateManager { + return new AuthStateManager(workerIndex, browserName, selfContainedSystemPrefix); } diff --git a/application/shared-webapp/tests/e2e/auth/storage-state.ts b/application/shared-webapp/tests/e2e/auth/storage-state.ts index b942cf1a3..0d5cdba0c 100644 --- a/application/shared-webapp/tests/e2e/auth/storage-state.ts +++ b/application/shared-webapp/tests/e2e/auth/storage-state.ts @@ -27,12 +27,17 @@ export async function loadAuthenticationState(_context: BrowserContext, filePath } /** - * Get the storage state file path for a specific worker, role, and system + * Get the storage state file path for a specific worker, role, browser, and system */ -export function getStorageStatePath(workerIndex: number, userRole: string, selfContainedSystemPrefix?: string): string { +export function getStorageStatePath( + workerIndex: number, + userRole: string, + browserName: string, + selfContainedSystemPrefix?: string +): string { const baseDir = path.join(process.cwd(), "tests/test-results/auth-state"); const systemPrefix = selfContainedSystemPrefix ?? "default"; - return path.join(baseDir, systemPrefix, `worker-${workerIndex}-${userRole.toLowerCase()}.json`); + return path.join(baseDir, systemPrefix, browserName, `worker-${workerIndex}-${userRole.toLowerCase()}.json`); } /** diff --git a/application/shared-webapp/tests/e2e/fixtures/page-auth.ts b/application/shared-webapp/tests/e2e/fixtures/page-auth.ts index e17c1792a..670ded8bb 100644 --- a/application/shared-webapp/tests/e2e/fixtures/page-auth.ts +++ b/application/shared-webapp/tests/e2e/fixtures/page-auth.ts @@ -108,10 +108,11 @@ async function createAuthenticatedContextAndPage( browser: Browser, role: UserRole, workerIndex: number, + browserName: string, selfContainedSystemPrefix?: string, tenant?: Tenant ): Promise<{ context: BrowserContext; page: Page }> { - const authManager = createAuthStateManager(workerIndex, selfContainedSystemPrefix); + const authManager = createAuthStateManager(workerIndex, browserName, selfContainedSystemPrefix); // Check if we have valid auth state const hasValidAuth = await authManager.hasValidAuthState(role); @@ -152,6 +153,7 @@ async function createAuthenticatedContextAndPage( export const test = base.extend({ ownerPage: async ({ browser }, use, testInfo) => { const workerIndex = testInfo.parallelIndex; + const browserName = testInfo.project.name; const systemPrefix = getSelfContainedSystemPrefix(); // Get tenant for this worker @@ -162,6 +164,7 @@ export const test = base.extend({ browser, "Owner", workerIndex, + browserName, systemPrefix, tenant ); @@ -174,6 +177,7 @@ export const test = base.extend({ adminPage: async ({ browser }, use, testInfo) => { const workerIndex = testInfo.parallelIndex; + const browserName = testInfo.project.name; const systemPrefix = getSelfContainedSystemPrefix(); // Get tenant for this worker @@ -184,6 +188,7 @@ export const test = base.extend({ browser, "Admin", workerIndex, + browserName, systemPrefix, tenant ); @@ -196,6 +201,7 @@ export const test = base.extend({ memberPage: async ({ browser }, use, testInfo) => { const workerIndex = testInfo.parallelIndex; + const browserName = testInfo.project.name; const systemPrefix = getSelfContainedSystemPrefix(); // Get tenant for this worker @@ -206,6 +212,7 @@ export const test = base.extend({ browser, "Member", workerIndex, + browserName, systemPrefix, tenant ); diff --git a/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts b/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts index 28e8317d3..8e0d6cbd8 100644 --- a/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts +++ b/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts @@ -1,4 +1,3 @@ -import { getStorageStatePath, isAuthenticationStateValid } from "@shared/e2e/auth/storage-state"; import { createTenantWithUsers, ensureTenantUsersExist } from "@shared/e2e/auth/tenant-provisioning"; import type { Tenant, TenantProvisioningOptions } from "@shared/e2e/types/auth"; @@ -34,10 +33,6 @@ export async function getWorkerTenant( } } - // Check if we have valid authentication state for the owner (primary user) - const ownerStorageStatePath = getStorageStatePath(workerIndex, "owner", selfContainedSystemPrefix); - const _hasValidAuth = await isAuthenticationStateValid(ownerStorageStatePath); - // Always create the tenant object structure const tenant = createTenantWithUsers(workerIndex, selfContainedSystemPrefix); From e68c78cd2cc982c14ef45a12b4973976ef4bfc23 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 14:03:51 +0100 Subject: [PATCH 3/9] Revert Rsbuild runtime error overlay causing end-to-end test failure --- .../shared-webapp/build/plugin/DevelopmentServerPlugin.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts b/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts index b07338ada..2f3441021 100644 --- a/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts +++ b/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts @@ -70,10 +70,7 @@ export function DevelopmentServerPlugin(options: DevelopmentServerPluginOptions) }, dev: { client: { - port: options.port, - overlay: { - runtime: process.env.NODE_ENV !== "production" - } + port: options.port }, // Set publicPath to auto to enable the server to serve the files assetPrefix: "auto", From 79c3837b2ec8ca99e3178d462a3b8aaff55e4e73 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 14:17:01 +0100 Subject: [PATCH 4/9] Add flaky test tracking skill for systematic E2E failure logging --- .../system-prompts/qa-engineer.txt | 8 + .../system-prompts/qa-reviewer.txt | 8 + .claude/skills/update-flaky-tests/SKILL.md | 153 ++++++++++++++++++ .../flaky-tests-archived-sample.json | 31 ++++ .../flaky-tests-sample.json | 80 +++++++++ .../flaky-tests-schema.json | 135 ++++++++++++++++ .../status-output-sample.md | 19 +++ 7 files changed, 434 insertions(+) create mode 100644 .claude/skills/update-flaky-tests/SKILL.md create mode 100644 .claude/skills/update-flaky-tests/flaky-tests-archived-sample.json create mode 100644 .claude/skills/update-flaky-tests/flaky-tests-sample.json create mode 100644 .claude/skills/update-flaky-tests/flaky-tests-schema.json create mode 100644 .claude/skills/update-flaky-tests/status-output-sample.md diff --git a/.claude/agentic-workflow/system-prompts/qa-engineer.txt b/.claude/agentic-workflow/system-prompts/qa-engineer.txt index 0022199c4..a40db8eae 100644 --- a/.claude/agentic-workflow/system-prompts/qa-engineer.txt +++ b/.claude/agentic-workflow/system-prompts/qa-engineer.txt @@ -71,3 +71,11 @@ Do not report (these are not system bugs): If you recover from a problem: Report problem + solution (2 calls). Report system bugs only. Every unreported workflow bug makes the agentic system worse. + +## Flaky Test Tracking + +**Run `/update-flaky-tests` immediately when:** +- Test failures occur that are unrelated to your current changes - log them +- You fix a known flaky test - mark it as fix-applied in the tracker + +The flaky test tracker helps the team understand which tests are unreliable. Always update it. diff --git a/.claude/agentic-workflow/system-prompts/qa-reviewer.txt b/.claude/agentic-workflow/system-prompts/qa-reviewer.txt index a2d3c2a67..95aafebf7 100644 --- a/.claude/agentic-workflow/system-prompts/qa-reviewer.txt +++ b/.claude/agentic-workflow/system-prompts/qa-reviewer.txt @@ -69,3 +69,11 @@ Do not report (these are not system bugs): If you recover from a problem: Report problem + solution (2 calls). Report system bugs only. Every unreported workflow bug makes the agentic system worse. + +## Flaky Test Tracking + +**Run `/update-flaky-tests` immediately when:** +- Test failures occur that are unrelated to the engineer's changes - log them +- You commit a fix for a known flaky test - mark it as fix-applied in the tracker + +The flaky test tracker helps the team understand which tests are unreliable. Always update it. diff --git a/.claude/skills/update-flaky-tests/SKILL.md b/.claude/skills/update-flaky-tests/SKILL.md new file mode 100644 index 000000000..5537ece02 --- /dev/null +++ b/.claude/skills/update-flaky-tests/SKILL.md @@ -0,0 +1,153 @@ +--- +name: update-flaky-tests +description: Update the flaky test tracker. Use when you encounter test failures unrelated to your current work, after committing a fix for a known flaky test, or to check flaky test status. +allowed-tools: Read, Write, Bash, Glob +--- + +# Update Flaky Tests + +Track and manage flaky E2E test observations over time. This skill helps systematically log test failures that are unrelated to the current work, preserving error artifacts for later analysis. + +## STEP 1: Load Database + +Read the flaky tests database from `.workspace/flaky-tests/flaky-tests.json`. + +If the file or folder doesn't exist: +1. Create the folder structure: `.workspace/flaky-tests/` and `.workspace/flaky-tests/artifacts/` +2. Initialize the database using the schema at `/.claude/skills/update-flaky-tests/flaky-tests-schema.json` +3. Create the main database file (`.workspace/flaky-tests/flaky-tests.json`): +```json +{ + "lastUpdated": "", + "active": [] +} +``` +4. Create an empty archive file (`.workspace/flaky-tests/flaky-tests-archived.json`): +```json +{ + "lastUpdated": "", + "active": [] +} +``` + +## STEP 2: Auto-Maintenance + +Perform automatic maintenance on every run: + +1. Find tests in `active` array with status `fix-applied` where `lastSeen` is more than 7 days ago +2. Move these tests to the archive file at `.workspace/flaky-tests/flaky-tests-archived.json` + - If archive file doesn't exist, create it with same structure: `{ "lastUpdated": "...", "active": [] }` + - Append tests to the archive's `active` array + - Remove tests from the main database's `active` array +3. Report any auto-archived tests: "Auto-archived X tests that have been stable for 7+ days: [test names]" + +## STEP 3: Determine Context + +Assess what action is needed based on your current context: + +| Context | Mode | +|---------|------| +| Just ran E2E tests with failures | **Log mode** | +| Just committed a fix for a known flaky test | **Fix mode** | +| Neither / standalone check | **Status mode** | + +## STEP 4: Execute Based on Mode + +### Log Mode (after test failures) + +For each test failure you observed: + +1. **Classify the failure**: + - Is it related to your current work? Skip it (fix it as part of your task) + - Is it unrelated (flaky)? Log it + +2. **For unrelated failures, check if already tracked**: + - Search `active` array for matching `testFile` + `testName` + `stepName` + `browser` + - If found: increment `observationCount`, update `lastSeen`, add new observation + - If not found: create new entry with status `observed` + +3. **Preserve error artifacts**: + - Find the error-context.md in `application/*/WebApp/tests/test-results/test-artifacts/` + - Create timestamped folder: `.workspace/flaky-tests/artifacts/{timestamp}-{testFile}-{browser}-{stepName}/` + - Copy error-context.md (and screenshots if present) to this folder + - Store relative path in observation's `artifactPath` field + +4. **Auto-promote status**: + - If `observationCount` >= 2, change status from `observed` to `confirmed` + +**Observation fields to populate**: +- `timestamp`: Current UTC timestamp (ISO 8601) +- `branch`: Current git branch +- `errorMessage`: The error message from the failure +- `artifactPath`: Relative path to preserved artifacts +- `observedBy`: Your agent type (qa-engineer, qa-reviewer, other) + +### Fix Mode (after committing a flaky test fix) + +1. Identify which flaky test was fixed (ask if unclear) +2. Find the test in the `active` array +3. Update the entry: + - Set `status` to `fix-applied` + - Populate the `fix` object: + - `appliedAt`: Current UTC timestamp + - `commitHash`: The commit hash of the fix + - `description`: Brief description of what was fixed + - `appliedBy`: Your agent type + +### Status Mode (standalone check) + +Read `/.claude/skills/update-flaky-tests/status-output-sample.md` first. Output status as a markdown table matching that format. Sort by Count descending. Omit Archived section if empty. End with legend line, nothing after. + +## STEP 5: Save Database + +1. Update `lastUpdated` to current UTC timestamp +2. Write the updated database to `.workspace/flaky-tests/flaky-tests.json` +3. Report changes made: + - "Added X new flaky test observations" + - "Updated X existing entries" + - "Marked X tests as fix-applied" + - "Auto-archived X resolved tests" + +## Key Rules + +**Only log tests you're confident are unrelated to your current work:** +- If the test fails in code you're changing, fix it - don't log it as flaky +- If you're unsure, err on the side of NOT logging + +**Preserve artifacts for comparison:** +- What looks like the same flaky test might have subtle differences +- Always copy the error-context.md when logging + +**Use local timestamps everywhere:** +- All `timestamp`, `lastSeen`, `appliedAt`, `lastUpdated` fields use local time +- Format: ISO 8601 without timezone suffix (e.g., `2026-01-14T14:30:00`) +- **Get current local time**: Run `date +"%Y-%m-%dT%H:%M:%S"` - never guess the time + +## Reference Files + +- **Schema**: `/.claude/skills/update-flaky-tests/flaky-tests-schema.json` +- **Sample database**: `/.claude/skills/update-flaky-tests/flaky-tests-sample.json` +- **Sample archive**: `/.claude/skills/update-flaky-tests/flaky-tests-archived-sample.json` +- **Sample status output**: `/.claude/skills/update-flaky-tests/status-output-sample.md` + +**Test entry structure** (unique key = testFile + testName + stepName + browser): +```json +{ + "testFile": "account-management/WebApp/tests/e2e/user-management-flows.spec.ts", + "testName": "should handle user invitation and deletion workflow", + "stepName": "Delete user & verify confirmation dialog closes", + "browser": "Firefox", + "errorPattern": "confirmation dialog still visible after close", + "status": "confirmed", + "observations": [...], + "lastSeen": "2026-01-14T10:30:00", + "observationCount": 3, + "fix": null, + "notes": "Timing issue with dialog close animation" +} +``` + +**Status lifecycle**: +``` +observed (1 observation) -> confirmed (2+ observations) -> fix-applied -> archived (7+ days stable) +``` diff --git a/.claude/skills/update-flaky-tests/flaky-tests-archived-sample.json b/.claude/skills/update-flaky-tests/flaky-tests-archived-sample.json new file mode 100644 index 000000000..3c58e7007 --- /dev/null +++ b/.claude/skills/update-flaky-tests/flaky-tests-archived-sample.json @@ -0,0 +1,31 @@ +{ + "lastUpdated": "2026-01-14T14:30:00", + "active": [ + { + "testFile": "account-management/WebApp/tests/e2e/login-flows.spec.ts", + "testName": "should complete login with OTP verification", + "stepName": "Enter verification code & verify redirect to dashboard", + "browser": "Firefox", + "errorPattern": "keyboard.type drops characters", + "status": "fix-applied", + "observations": [ + { + "timestamp": "2026-01-05T11:00:00", + "branch": "main", + "errorMessage": "keyboard.type failed to enter all characters", + "artifactPath": "2026-01-05T11-00-00Z-login-flows-enter-verification-code/", + "observedBy": "qa-engineer" + } + ], + "lastSeen": "2026-01-05T11:00:00", + "observationCount": 1, + "fix": { + "appliedAt": "2026-01-06T10:00:00", + "commitHash": "d6b6b25b5", + "description": "Fix OTP typing reliability in Firefox during parallel test execution", + "appliedBy": "qa-engineer" + }, + "notes": "Stable for 7+ days after fix" + } + ] +} diff --git a/.claude/skills/update-flaky-tests/flaky-tests-sample.json b/.claude/skills/update-flaky-tests/flaky-tests-sample.json new file mode 100644 index 000000000..864dfa2dd --- /dev/null +++ b/.claude/skills/update-flaky-tests/flaky-tests-sample.json @@ -0,0 +1,80 @@ +{ + "lastUpdated": "2026-01-14T14:30:00", + "active": [ + { + "testFile": "account-management/WebApp/tests/e2e/login-flows.spec.ts", + "testName": "should complete login with OTP verification", + "stepName": "Click login button & wait for navigation", + "browser": "Firefox", + "errorPattern": "button click timeout", + "status": "observed", + "observations": [ + { + "timestamp": "2026-01-14T10:00:00", + "branch": "pp-765-stabilize-flaky-e2e-tests", + "errorMessage": "Timeout waiting for button to be clickable after 5000ms", + "artifactPath": "2026-01-14T10-00-00-login-flows-click-login/", + "observedBy": "qa-engineer" + } + ], + "lastSeen": "2026-01-14T10:00:00", + "observationCount": 1, + "fix": null, + "notes": null + }, + { + "testFile": "account-management/WebApp/tests/e2e/user-management-flows.spec.ts", + "testName": "should handle user invitation and deletion workflow", + "stepName": "Delete user & verify confirmation dialog closes", + "browser": "Firefox", + "errorPattern": "confirmation dialog still visible after close", + "status": "confirmed", + "observations": [ + { + "timestamp": "2026-01-13T15:00:00", + "branch": "pp-765-stabilize-flaky-e2e-tests", + "errorMessage": "Expected element to not be visible but it was still present after 5000ms", + "artifactPath": "2026-01-13T15-00-00Z-user-management-flows-delete-user/", + "observedBy": "qa-engineer" + }, + { + "timestamp": "2026-01-14T10:30:00", + "branch": "pp-765-stabilize-flaky-e2e-tests", + "errorMessage": "Expected element to not be visible but it was still present after 5000ms", + "artifactPath": "2026-01-14T10-30-00Z-user-management-flows-delete-user/", + "observedBy": "qa-engineer" + } + ], + "lastSeen": "2026-01-14T10:30:00", + "observationCount": 2, + "fix": null, + "notes": "Timing issue with dialog close animation in Firefox" + }, + { + "testFile": "account-management/WebApp/tests/e2e/global-ui-flows.spec.ts", + "testName": "should handle theme switching and navigation", + "stepName": "Navigate to admin dashboard & verify welcome heading", + "browser": "WebKit", + "errorPattern": "heading not visible after navigation", + "status": "fix-applied", + "observations": [ + { + "timestamp": "2026-01-12T09:15:00", + "branch": "main", + "errorMessage": "Timeout waiting for heading 'Welcome home' to be visible", + "artifactPath": "2026-01-12T09-15-00Z-global-ui-flows-navigate-dashboard/", + "observedBy": "qa-reviewer" + } + ], + "lastSeen": "2026-01-12T09:15:00", + "observationCount": 1, + "fix": { + "appliedAt": "2026-01-13T14:00:00", + "commitHash": "abc123def", + "description": "Added browser-specific auth state paths to fix cross-browser test isolation", + "appliedBy": "qa-engineer" + }, + "notes": "WebKit ownerPage fixture was not properly isolated between tests" + } + ] +} diff --git a/.claude/skills/update-flaky-tests/flaky-tests-schema.json b/.claude/skills/update-flaky-tests/flaky-tests-schema.json new file mode 100644 index 000000000..d661f01e6 --- /dev/null +++ b/.claude/skills/update-flaky-tests/flaky-tests-schema.json @@ -0,0 +1,135 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Flaky Tests Database", + "description": "Schema for tracking flaky E2E tests over time", + "type": "object", + "required": ["lastUpdated", "active"], + "properties": { + "lastUpdated": { + "type": "string", + "format": "date-time", + "description": "UTC timestamp of last database update" + }, + "active": { + "type": "array", + "description": "Currently tracked flaky tests", + "items": { + "$ref": "#/$defs/flakyTest" + } + } + }, + "$defs": { + "flakyTest": { + "type": "object", + "required": ["testFile", "testName", "stepName", "browser", "status", "lastSeen", "observationCount", "observations"], + "description": "Unique key is testFile + testName + stepName + browser. Each browser gets its own entry.", + "properties": { + "testFile": { + "type": "string", + "description": "Test file path including self-contained system (e.g., account-management/WebApp/tests/e2e/user-management-flows.spec.ts)" + }, + "testName": { + "type": "string", + "description": "Full test name from the test description" + }, + "stepName": { + "type": "string", + "description": "The step where the failure occurred" + }, + "browser": { + "type": "string", + "enum": ["Chromium", "Firefox", "WebKit"], + "description": "Browser where the failure was observed" + }, + "errorPattern": { + "type": "string", + "description": "Common error message pattern for this flaky test" + }, + "status": { + "type": "string", + "enum": ["observed", "confirmed", "fix-applied"], + "description": "Current status in the flaky test lifecycle" + }, + "observations": { + "type": "array", + "description": "Individual failure observations", + "items": { + "$ref": "#/$defs/observation" + } + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "description": "UTC timestamp of most recent observation" + }, + "observationCount": { + "type": "integer", + "minimum": 1, + "description": "Total number of times this test has been observed failing" + }, + "fix": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/fix" } + ], + "description": "Fix information if a fix has been applied" + }, + "notes": { + "type": "string", + "description": "Optional notes about the flaky test (suspected cause, workarounds, etc.)" + } + } + }, + "observation": { + "type": "object", + "required": ["timestamp", "branch", "errorMessage", "observedBy"], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "UTC timestamp when observed" + }, + "branch": { + "type": "string", + "description": "Git branch where failure was observed" + }, + "errorMessage": { + "type": "string", + "description": "The actual error message from the test failure" + }, + "artifactPath": { + "type": "string", + "description": "Relative path to preserved error artifacts in .workspace/flaky-tests/artifacts/" + }, + "observedBy": { + "type": "string", + "enum": ["qa-engineer", "qa-reviewer", "other"], + "description": "Agent type that observed this failure" + } + } + }, + "fix": { + "type": "object", + "required": ["appliedAt", "commitHash", "appliedBy"], + "properties": { + "appliedAt": { + "type": "string", + "format": "date-time", + "description": "UTC timestamp when fix was applied" + }, + "commitHash": { + "type": "string", + "description": "Git commit hash containing the fix" + }, + "description": { + "type": "string", + "description": "Brief description of what was fixed" + }, + "appliedBy": { + "type": "string", + "description": "Agent type that applied the fix" + } + } + } + } +} diff --git a/.claude/skills/update-flaky-tests/status-output-sample.md b/.claude/skills/update-flaky-tests/status-output-sample.md new file mode 100644 index 000000000..398aed1af --- /dev/null +++ b/.claude/skills/update-flaky-tests/status-output-sample.md @@ -0,0 +1,19 @@ +# Flaky Test Tracker + +## Active (3) + +| Status | Test | Browser | Count | Last Seen | +|--------|------|---------|-------|-----------| +| 🟑 | user-management-flows.spec.ts | Firefox | 2 | Jan 14 10:30 | +| πŸ”΄ | login-flows.spec.ts | Firefox | 1 | Jan 14 10:00 | +| 🟒 | global-ui-flows.spec.ts | WebKit | 1 | Jan 12 09:15 | + +## Archived (1) + +| Test | Browser | Fixed | Stable Since | +|------|---------|-------|--------------| +| login-flows.spec.ts | Firefox | Jan 6 | Jan 13 | + +--- + +πŸ”΄ Observed Β· 🟑 Confirmed Β· 🟒 Fix Applied From 0cf8bd820fb16d164eb2ddc73c86177f9ea3a3c4 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 15:55:09 +0100 Subject: [PATCH 5/9] Fix WebKit auth validation race condition in ownerPage fixture --- .../shared-webapp/tests/e2e/auth/auth-state-manager.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts index 89856e7fa..a67e0a023 100644 --- a/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts +++ b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts @@ -73,8 +73,13 @@ export class AuthStateManager { // Navigate to a protected route await page.goto("/admin"); - // If we get redirected to login, auth is invalid - // If we stay on /admin (or any admin route), auth is valid + // Wait for page content to stabilize (admin sidebar or login form) + await Promise.race([ + page.locator("[data-sidebar]").waitFor({ state: "visible" }), + page.locator('form input[type="email"]').waitFor({ state: "visible" }) + ]); + + // If we stayed on /admin, auth is valid. If redirected to /login, auth is invalid. return !page.url().includes("/login"); } catch { // If any error occurs during validation, consider auth invalid From 2ea23bca81efe5196cd4fc910689ad42ba3c7850 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 17:41:09 +0100 Subject: [PATCH 6/9] Replace useEffect toast anti-pattern with onSuccess callbacks --- .../common/UserProfileModal.tsx | 244 +++++++++--------- .../WebApp/routes/admin/account/index.tsx | 24 +- .../-components/ChangeUserRoleDialog.tsx | 12 +- .../users/-components/InviteUserDialog.tsx | 100 +++---- .../WebApp/routes/login/verify.tsx | 71 +++-- .../WebApp/routes/signup/verify.tsx | 74 +++--- .../infrastructure/http/queryClient.ts | 5 +- 7 files changed, 269 insertions(+), 261 deletions(-) diff --git a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx index 09e8d6538..223d319dd 100644 --- a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx +++ b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx @@ -60,7 +60,8 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly { + const handleCancel = () => { + handleCloseComplete(); onOpenChange(false); }; @@ -92,11 +93,8 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly { - if (saveMutation.isSuccess) { + }, + onSuccess: () => { toastQueue.add({ title: t`Success`, description: t`Profile updated successfully`, @@ -104,7 +102,7 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly { if (files?.[0]) { @@ -152,130 +150,134 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly - - Update your profile picture and personal details here.}> - - User profile - - + {({ close }) => ( + <> + + Update your profile picture and personal details here.}> + + User profile + + -
- - { - setAvatarMenuOpen(false); - onFileSelect(files); - }} - acceptedFileTypes={ALLOWED_FILE_TYPES} - /> + + + { + setAvatarMenuOpen(false); + onFileSelect(files); + }} + acceptedFileTypes={ALLOWED_FILE_TYPES} + /> - + - - - - { - avatarFileInputRef.current?.click(); - }} - > - - Upload profile picture - - {(user.avatarUrl || avatarPreviewUrl) && ( - <> - + + + { - setAvatarMenuOpen(false); - setRemoveAvatarFlag(true); - setSelectedAvatarFile(null); - setAvatarPreviewUrl(null); - user.avatarUrl = null; + avatarFileInputRef.current?.click(); }} > - - - Remove profile picture - + + Upload profile picture - - )} - - + {(user.avatarUrl || avatarPreviewUrl) && ( + <> + + { + setAvatarMenuOpen(false); + setRemoveAvatarFlag(true); + setSelectedAvatarFile(null); + setAvatarPreviewUrl(null); + user.avatarUrl = null; + }} + > + + + Remove profile picture + + + + )} + + -
- setIsFormDirty(true)} - /> - setIsFormDirty(true)} - /> -
- } - /> - setIsFormDirty(true)} - /> -
+
+ setIsFormDirty(true)} + /> + setIsFormDirty(true)} + /> +
+ } + /> + setIsFormDirty(true)} + /> +
- - - - -
+ + + + + + + )} )} diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index 72529ee96..26e22da4d 100644 --- a/application/account-management/WebApp/routes/admin/account/index.tsx +++ b/application/account-management/WebApp/routes/admin/account/index.tsx @@ -251,7 +251,17 @@ export function AccountSettings() { refetch: refetchTenant } = api.useQuery("get", "/api/account-management/tenants/current"); const { data: currentUser, isLoading: userLoading } = api.useQuery("get", "/api/account-management/users/me"); - const updateCurrentTenantMutation = api.useMutation("put", "/api/account-management/tenants/current"); + const updateCurrentTenantMutation = api.useMutation("put", "/api/account-management/tenants/current", { + onSuccess: () => { + setIsFormDirty(false); + toastQueue.add({ + title: t`Success`, + description: t`Account name updated successfully`, + variant: "success" + }); + refetchTenant(); + } + }); const updateTenantLogoMutation = api.useMutation("post", "/api/account-management/tenants/current/update-logo"); const removeTenantLogoMutation = api.useMutation("delete", "/api/account-management/tenants/current/remove-logo"); @@ -269,18 +279,6 @@ export function AccountSettings() { hasUnsavedChanges: isFormDirty && isOwner }); - useEffect(() => { - if (updateCurrentTenantMutation.isSuccess) { - setIsFormDirty(false); - toastQueue.add({ - title: t`Success`, - description: t`Account name updated successfully`, - variant: "success" - }); - refetchTenant(); - } - }, [updateCurrentTenantMutation.isSuccess, refetchTenant]); - // Dispatch event to notify components about tenant updates useEffect(() => { if ( diff --git a/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx index 9ecd9a559..938ffb5a1 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx @@ -53,7 +53,8 @@ export function ChangeUserRoleDialog({ user, isOpen, onOpenChange }: Readonly { + const handleCancel = () => { + setSelectedRole(null); onOpenChange(false); }; @@ -83,12 +84,9 @@ export function ChangeUserRoleDialog({ user, isOpen, onOpenChange }: Readonly - {() => ( + {({ close }) => ( <> - + Change user role @@ -163,7 +161,7 @@ export function ChangeUserRoleDialog({ user, isOpen, onOpenChange }: Readonly - - - +
+ + setIsFormDirty(true)} + /> + + + + + +
+ + )}
); diff --git a/application/account-management/WebApp/routes/login/verify.tsx b/application/account-management/WebApp/routes/login/verify.tsx index 7b211f70b..817e50351 100644 --- a/application/account-management/WebApp/routes/login/verify.tsx +++ b/application/account-management/WebApp/routes/login/verify.tsx @@ -119,15 +119,24 @@ export function CompleteLoginForm() { } }, [email]); - const completeLoginMutation = api.useMutation("post", "/api/account-management/authentication/login/{id}/complete"); + const resetAfterResend = useCallback((validForSeconds: number) => { + const newExpireAt = new Date(); + newExpireAt.setSeconds(newExpireAt.getSeconds() + validForSeconds); + setExpireAt(newExpireAt); + getLoginState().expireAt = newExpireAt; - const resendLoginCodeMutation = api.useMutation( - "post", - "/api/account-management/authentication/login/{emailConfirmationId}/resend-code" - ); + setIsOneTimeCodeComplete(false); + setShowRequestLink(false); + setIsRateLimited(false); - useEffect(() => { - if (completeLoginMutation.isSuccess) { + setTimeout(() => { + oneTimeCodeInputRef.current?.reset?.(); + oneTimeCodeInputRef.current?.focus?.(); + }, 100); + }, []); + + const completeLoginMutation = api.useMutation("post", "/api/account-management/authentication/login/{id}/complete", { + onSuccess: () => { // Broadcast login event to other tabs // Since the API returns 204 No Content, we don't have the user ID yet const message: Omit = { @@ -141,7 +150,25 @@ export function CompleteLoginForm() { clearLoginState(); window.location.href = returnPath ?? loggedInPath; } - }, [completeLoginMutation.isSuccess, returnPath, email, getPreferredTenantId]); + }); + + const resendLoginCodeMutation = api.useMutation( + "post", + "/api/account-management/authentication/login/{emailConfirmationId}/resend-code", + { + onSuccess: (data) => { + if (data) { + resetAfterResend(data.validForSeconds); + setHasRequestedNewCode(true); + toastQueue.add({ + title: t`Verification code sent`, + description: t`A new verification code has been sent to your email.`, + variant: "success" + }); + } + } + } + ); useEffect(() => { if (completeLoginMutation.isError) { @@ -159,34 +186,6 @@ export function CompleteLoginForm() { } }, [completeLoginMutation.isError, completeLoginMutation.error]); - const resetAfterResend = useCallback((validForSeconds: number) => { - const newExpireAt = new Date(); - newExpireAt.setSeconds(newExpireAt.getSeconds() + validForSeconds); - setExpireAt(newExpireAt); - getLoginState().expireAt = newExpireAt; - - setIsOneTimeCodeComplete(false); - setShowRequestLink(false); - setIsRateLimited(false); - - setTimeout(() => { - oneTimeCodeInputRef.current?.reset?.(); - oneTimeCodeInputRef.current?.focus?.(); - }, 100); - }, []); - - useEffect(() => { - if (resendLoginCodeMutation.isSuccess && resendLoginCodeMutation.data) { - resetAfterResend(resendLoginCodeMutation.data.validForSeconds); - setHasRequestedNewCode(true); - toastQueue.add({ - title: t`Verification code sent`, - description: t`A new verification code has been sent to your email.`, - variant: "success" - }); - } - }, [resendLoginCodeMutation.isSuccess, resendLoginCodeMutation.data, resetAfterResend]); - const expiresInString = `${Math.floor(secondsRemaining / 60)}:${String(secondsRemaining % 60).padStart(2, "0")}`; if (!loginId) { diff --git a/application/account-management/WebApp/routes/signup/verify.tsx b/application/account-management/WebApp/routes/signup/verify.tsx index f51926cfe..b353aef95 100644 --- a/application/account-management/WebApp/routes/signup/verify.tsx +++ b/application/account-management/WebApp/routes/signup/verify.tsx @@ -101,22 +101,50 @@ export function CompleteSignupForm() { } }, [isExpired, showRequestLink, hasRequestedNewCode]); + const resetAfterResend = useCallback((validForSeconds: number) => { + const newExpireAt = new Date(); + newExpireAt.setSeconds(newExpireAt.getSeconds() + validForSeconds); + setExpireAt(newExpireAt); + getSignupState().expireAt = newExpireAt; + + setIsOneTimeCodeComplete(false); + setShowRequestLink(false); + setIsRateLimited(false); + + setTimeout(() => { + oneTimeCodeInputRef.current?.reset?.(); + oneTimeCodeInputRef.current?.focus?.(); + }, 100); + }, []); + const completeSignupMutation = api.useMutation( "post", - "/api/account-management/signups/{emailConfirmationId}/complete" + "/api/account-management/signups/{emailConfirmationId}/complete", + { + onSuccess: () => { + clearSignupState(); + window.location.href = loggedInPath; + } + } ); const resendSignupCodeMutation = api.useMutation( "post", - "/api/account-management/signups/{emailConfirmationId}/resend-code" - ); - - useEffect(() => { - if (completeSignupMutation.isSuccess) { - clearSignupState(); - window.location.href = loggedInPath; + "/api/account-management/signups/{emailConfirmationId}/resend-code", + { + onSuccess: (data) => { + if (data) { + resetAfterResend(data.validForSeconds); + setHasRequestedNewCode(true); + toastQueue.add({ + title: t`Verification code sent`, + description: t`A new verification code has been sent to your email.`, + variant: "success" + }); + } + } } - }, [completeSignupMutation.isSuccess]); + ); useEffect(() => { if (completeSignupMutation.isError) { @@ -134,34 +162,6 @@ export function CompleteSignupForm() { } }, [completeSignupMutation.isError, completeSignupMutation.error]); - const resetAfterResend = useCallback((validForSeconds: number) => { - const newExpireAt = new Date(); - newExpireAt.setSeconds(newExpireAt.getSeconds() + validForSeconds); - setExpireAt(newExpireAt); - getSignupState().expireAt = newExpireAt; - - setIsOneTimeCodeComplete(false); - setShowRequestLink(false); - setIsRateLimited(false); - - setTimeout(() => { - oneTimeCodeInputRef.current?.reset?.(); - oneTimeCodeInputRef.current?.focus?.(); - }, 100); - }, []); - - useEffect(() => { - if (resendSignupCodeMutation.isSuccess && resendSignupCodeMutation.data) { - resetAfterResend(resendSignupCodeMutation.data.validForSeconds); - setHasRequestedNewCode(true); - toastQueue.add({ - title: t`Verification code sent`, - description: t`A new verification code has been sent to your email.`, - variant: "success" - }); - } - }, [resendSignupCodeMutation.isSuccess, resendSignupCodeMutation.data, resetAfterResend]); - const expiresInString = `${Math.floor(secondsRemaining / 60)}:${String(secondsRemaining % 60).padStart(2, "0")}`; return ( diff --git a/application/shared-webapp/infrastructure/http/queryClient.ts b/application/shared-webapp/infrastructure/http/queryClient.ts index 1e10316ec..e9ed1bec4 100644 --- a/application/shared-webapp/infrastructure/http/queryClient.ts +++ b/application/shared-webapp/infrastructure/http/queryClient.ts @@ -100,7 +100,10 @@ export const queryClient = new QueryClient({ } }), mutationCache: new MutationCache({ - onSuccess: () => { + onSuccess: (_data, _variables, _context, mutation) => { + if (mutation.meta?.skipQueryInvalidation) { + return; + } queryClient.invalidateQueries(); } }) From 9c5df9d5f006984cd3f70c7f3dc0631eae1be863 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 17:55:35 +0100 Subject: [PATCH 7/9] Add toast timing and dirty modal patterns to frontend rules --- .agent/rules/frontend/frontend.md | 98 ++++++++++++++-------- .claude/rules/frontend/frontend.md | 98 ++++++++++++++-------- .cursor/rules/frontend/frontend.mdc | 98 ++++++++++++++-------- .github/copilot/rules/frontend/frontend.md | 98 ++++++++++++++-------- .windsurf/rules/frontend/frontend.md | 98 ++++++++++++++-------- 5 files changed, 325 insertions(+), 165 deletions(-) diff --git a/.agent/rules/frontend/frontend.md b/.agent/rules/frontend/frontend.md index 068062526..f93261a11 100644 --- a/.agent/rules/frontend/frontend.md +++ b/.agent/rules/frontend/frontend.md @@ -74,6 +74,7 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - **Errors are handled globally**β€”`shared-webapp/infrastructure/http/errorHandler.ts` automatically shows toast notifications with the server's error message (don't manually show toasts for errors) - **Validation errors**: Pass to forms via `validationErrors={mutation.error?.errors}` - **`onError` is for UI cleanup only** (resetting loading states, closing dialogs), not for showing errors + - **Toast notifications**: Show success toasts in mutation `onSuccess` callbacks, not in `useEffect` watching `isSuccess` (avoids React effect scheduling delays) 4. Responsive design utilities: - Use `useViewportResize()` hook to detect mobile viewport (returns `true` when mobile) @@ -92,16 +93,21 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - `z-[200]`: Mobile full-screen menus - Note: Dropdowns, tooltips, and popovers use React Aria's overlay system which manages stacking relative to their context -6. Always follow these steps when implementing changes: +6. DirtyModal close handlers: + - **X button**: Use Dialog's `close` from render prop (shows unsaved warning if dirty) + - **Cancel button**: Use `handleCancel` that clears state and closes immediately (bypasses warning) + - Always clear dirty state in `onSuccess` and `onCloseComplete` + +7. Always follow these steps when implementing changes: - Consult relevant rule files and list which ones guided your implementation - Search the codebase for similar code before implementing new code - Reference existing implementations to maintain consistency -7. Build and format your changes: +8. Build and format your changes: - After each minor change, use the **execute MCP tool** with `command: "build"` for frontend - This ensures consistent code style across the codebase -8. Verify your changes: +9. Verify your changes: - When a feature is complete, run these MCP tools for frontend in sequence: **build**, **format**, **inspect** - **ALL inspect findings are blocking** - CI pipeline fails on any result marked "Issues found" - Severity level (note/warning/error) is irrelevant - fix all findings before proceeding @@ -111,39 +117,49 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v ```tsx // βœ… DO: Correct patterns -export function UserPicker({ isOpen, isPending, onOpenChange }: UserPickerProps) { +export function UserPicker({ isOpen, onOpenChange }: UserPickerProps) { + const [isFormDirty, setIsFormDirty] = useState(false); const { data } = api.useQuery("get", "/api/account-management/users", { enabled: isOpen }); const activeUsers = (data?.users ?? []).filter((u) => u.isActive); // βœ… Compute derived values inline - const handleChangeSelection = (keys: Selection) => { /* ... */ }; // βœ… handleVerbNoun pattern + const inviteMutation = api.useMutation("post", "/api/account-management/users/invite", { + onSuccess: () => { // βœ… Show toast in onSuccess (not useEffect) + setIsFormDirty(false); + toastQueue.add({ title: t`Success`, description: t`User invited`, variant: "success" }); + onOpenChange(false); + } + }); + + const handleCloseComplete = () => setIsFormDirty(false); + const handleCancel = () => { setIsFormDirty(false); onOpenChange(false); }; // βœ… Clear state + close (bypasses warning) return ( - // βœ… Prevent dismiss during pending + // βœ… Use dialog width classes (not max-w-lg) {({ close }) => ( // βœ… Dialog render prop provides close function <> - // βœ… Close button pattern (onClick is exception) + // βœ… X uses close (shows warning if dirty) Select users - - - {activeUsers.map((user) => ( - - {`${user.firstName} ${user.lastName}`} - - ))} - - - - - +
+ + setIsFormDirty(true)} /> + + + + + +
)}
-
+ ); } @@ -152,11 +168,19 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { const [filteredUsers, setFilteredUsers] = useState([]); // ❌ State for derived values const [isAdmin, setIsAdmin] = useState(false); // ❌ Duplicate state that can be calculated + const inviteMutation = api.useMutation("post", "/api/users/invite"); + useEffect(() => { // ❌ useEffect for calculations - compute inline instead setFilteredUsers(users.filter(u => u.isActive)); setIsAdmin(users.some(u => u.id === selectedId && u.role === "admin")); // ❌ Hardcode strings - use API contract types }, [users, selectedId]); + useEffect(() => { // ❌ useEffect watching isSuccess causes toast timing issues + if (inviteMutation.isSuccess) { + toastQueue.add({ title: "Success", variant: "success" }); + } + }, [inviteMutation.isSuccess]); + const getDisplayName = useCallback((user) => { // ❌ Premature useCallback without performance need return `${user.firstName} ${user.lastName}`; }, []); @@ -166,17 +190,25 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { return ( // ❌ Missing isDismissable={!isPending} // ❌ max-w-lg (use w-dialog-md), hardcoded colors (use bg-background) -

User Mgmt

// ❌ Native

(use Heading), acronym "Mgmt", missing -
    // ❌ Native
      - use ListBox - {filteredUsers.map(user => ( -
    • handleSelect(user.id)}> // ❌ Native
    • , onClick (use onAction) - // ❌ Native - use Avatar - {user.email} // ❌ text-sm with Text causes blur - {getDisplayName(user)} -
    • - ))} -
    - // ❌ Missing isDisabled/isPending, missing + {({ close }) => ( // ❌ Both X and Cancel use close (Cancel should use handleCancel) + <> + +

    User Mgmt

    // ❌ Native

    (use Heading), acronym "Mgmt", missing +
      // ❌ Native
        - use ListBox + {filteredUsers.map(user => ( +
      • handleSelect(user.id)}> // ❌ Native
      • , onClick (use onAction) + // ❌ Native - use Avatar + {user.email} // ❌ text-sm with Text causes blur + {getDisplayName(user)} +
      • + ))} +
      + // ❌ Cancel uses close (shows unwanted warning) + + + )}

); diff --git a/.claude/rules/frontend/frontend.md b/.claude/rules/frontend/frontend.md index 218221d22..07ded578a 100644 --- a/.claude/rules/frontend/frontend.md +++ b/.claude/rules/frontend/frontend.md @@ -74,6 +74,7 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - **Errors are handled globally**β€”`shared-webapp/infrastructure/http/errorHandler.ts` automatically shows toast notifications with the server's error message (don't manually show toasts for errors) - **Validation errors**: Pass to forms via `validationErrors={mutation.error?.errors}` - **`onError` is for UI cleanup only** (resetting loading states, closing dialogs), not for showing errors + - **Toast notifications**: Show success toasts in mutation `onSuccess` callbacks, not in `useEffect` watching `isSuccess` (avoids React effect scheduling delays) 4. Responsive design utilities: - Use `useViewportResize()` hook to detect mobile viewport (returns `true` when mobile) @@ -92,16 +93,21 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - `z-[200]`: Mobile full-screen menus - Note: Dropdowns, tooltips, and popovers use React Aria's overlay system which manages stacking relative to their context -6. Always follow these steps when implementing changes: +6. DirtyModal close handlers: + - **X button**: Use Dialog's `close` from render prop (shows unsaved warning if dirty) + - **Cancel button**: Use `handleCancel` that clears state and closes immediately (bypasses warning) + - Always clear dirty state in `onSuccess` and `onCloseComplete` + +7. Always follow these steps when implementing changes: - Consult relevant rule files and list which ones guided your implementation - Search the codebase for similar code before implementing new code - Reference existing implementations to maintain consistency -7. Build and format your changes: +8. Build and format your changes: - After each minor change, use the **execute MCP tool** with `command: "build"` for frontend - This ensures consistent code style across the codebase -8. Verify your changes: +9. Verify your changes: - When a feature is complete, run these MCP tools for frontend in sequence: **build**, **format**, **inspect** - **ALL inspect findings are blocking** - CI pipeline fails on any result marked "Issues found" - Severity level (note/warning/error) is irrelevant - fix all findings before proceeding @@ -111,39 +117,49 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v ```tsx // βœ… DO: Correct patterns -export function UserPicker({ isOpen, isPending, onOpenChange }: UserPickerProps) { +export function UserPicker({ isOpen, onOpenChange }: UserPickerProps) { + const [isFormDirty, setIsFormDirty] = useState(false); const { data } = api.useQuery("get", "/api/account-management/users", { enabled: isOpen }); const activeUsers = (data?.users ?? []).filter((u) => u.isActive); // βœ… Compute derived values inline - const handleChangeSelection = (keys: Selection) => { /* ... */ }; // βœ… handleVerbNoun pattern + const inviteMutation = api.useMutation("post", "/api/account-management/users/invite", { + onSuccess: () => { // βœ… Show toast in onSuccess (not useEffect) + setIsFormDirty(false); + toastQueue.add({ title: t`Success`, description: t`User invited`, variant: "success" }); + onOpenChange(false); + } + }); + + const handleCloseComplete = () => setIsFormDirty(false); + const handleCancel = () => { setIsFormDirty(false); onOpenChange(false); }; // βœ… Clear state + close (bypasses warning) return ( - // βœ… Prevent dismiss during pending + // βœ… Use dialog width classes (not max-w-lg) {({ close }) => ( // βœ… Dialog render prop provides close function <> - // βœ… Close button pattern (onClick is exception) + // βœ… X uses close (shows warning if dirty) Select users - - - {activeUsers.map((user) => ( - - {`${user.firstName} ${user.lastName}`} - - ))} - - - - - +
+ + setIsFormDirty(true)} /> + + + + + +
)}
-
+ ); } @@ -152,11 +168,19 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { const [filteredUsers, setFilteredUsers] = useState([]); // ❌ State for derived values const [isAdmin, setIsAdmin] = useState(false); // ❌ Duplicate state that can be calculated + const inviteMutation = api.useMutation("post", "/api/users/invite"); + useEffect(() => { // ❌ useEffect for calculations - compute inline instead setFilteredUsers(users.filter(u => u.isActive)); setIsAdmin(users.some(u => u.id === selectedId && u.role === "admin")); // ❌ Hardcode strings - use API contract types }, [users, selectedId]); + useEffect(() => { // ❌ useEffect watching isSuccess causes toast timing issues + if (inviteMutation.isSuccess) { + toastQueue.add({ title: "Success", variant: "success" }); + } + }, [inviteMutation.isSuccess]); + const getDisplayName = useCallback((user) => { // ❌ Premature useCallback without performance need return `${user.firstName} ${user.lastName}`; }, []); @@ -166,17 +190,25 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { return ( // ❌ Missing isDismissable={!isPending} // ❌ max-w-lg (use w-dialog-md), hardcoded colors (use bg-background) -

User Mgmt

// ❌ Native

(use Heading), acronym "Mgmt", missing -
    // ❌ Native
      - use ListBox - {filteredUsers.map(user => ( -
    • handleSelect(user.id)}> // ❌ Native
    • , onClick (use onAction) - // ❌ Native - use Avatar - {user.email} // ❌ text-sm with Text causes blur - {getDisplayName(user)} -
    • - ))} -
    - // ❌ Missing isDisabled/isPending, missing + {({ close }) => ( // ❌ Both X and Cancel use close (Cancel should use handleCancel) + <> + +

    User Mgmt

    // ❌ Native

    (use Heading), acronym "Mgmt", missing +
      // ❌ Native
        - use ListBox + {filteredUsers.map(user => ( +
      • handleSelect(user.id)}> // ❌ Native
      • , onClick (use onAction) + // ❌ Native - use Avatar + {user.email} // ❌ text-sm with Text causes blur + {getDisplayName(user)} +
      • + ))} +
      + // ❌ Cancel uses close (shows unwanted warning) + + + )}

); diff --git a/.cursor/rules/frontend/frontend.mdc b/.cursor/rules/frontend/frontend.mdc index e9647bc8f..e756e8c65 100644 --- a/.cursor/rules/frontend/frontend.mdc +++ b/.cursor/rules/frontend/frontend.mdc @@ -74,6 +74,7 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - **Errors are handled globally**β€”`shared-webapp/infrastructure/http/errorHandler.ts` automatically shows toast notifications with the server's error message (don't manually show toasts for errors) - **Validation errors**: Pass to forms via `validationErrors={mutation.error?.errors}` - **`onError` is for UI cleanup only** (resetting loading states, closing dialogs), not for showing errors + - **Toast notifications**: Show success toasts in mutation `onSuccess` callbacks, not in `useEffect` watching `isSuccess` (avoids React effect scheduling delays) 4. Responsive design utilities: - Use `useViewportResize()` hook to detect mobile viewport (returns `true` when mobile) @@ -92,16 +93,21 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - `z-[200]`: Mobile full-screen menus - Note: Dropdowns, tooltips, and popovers use React Aria's overlay system which manages stacking relative to their context -6. Always follow these steps when implementing changes: +6. DirtyModal close handlers: + - **X button**: Use Dialog's `close` from render prop (shows unsaved warning if dirty) + - **Cancel button**: Use `handleCancel` that clears state and closes immediately (bypasses warning) + - Always clear dirty state in `onSuccess` and `onCloseComplete` + +7. Always follow these steps when implementing changes: - Consult relevant rule files and list which ones guided your implementation - Search the codebase for similar code before implementing new code - Reference existing implementations to maintain consistency -7. Build and format your changes: +8. Build and format your changes: - After each minor change, use the **execute MCP tool** with `command: "build"` for frontend - This ensures consistent code style across the codebase -8. Verify your changes: +9. Verify your changes: - When a feature is complete, run these MCP tools for frontend in sequence: **build**, **format**, **inspect** - **ALL inspect findings are blocking** - CI pipeline fails on any result marked "Issues found" - Severity level (note/warning/error) is irrelevant - fix all findings before proceeding @@ -111,39 +117,49 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v ```tsx // βœ… DO: Correct patterns -export function UserPicker({ isOpen, isPending, onOpenChange }: UserPickerProps) { +export function UserPicker({ isOpen, onOpenChange }: UserPickerProps) { + const [isFormDirty, setIsFormDirty] = useState(false); const { data } = api.useQuery("get", "/api/account-management/users", { enabled: isOpen }); const activeUsers = (data?.users ?? []).filter((u) => u.isActive); // βœ… Compute derived values inline - const handleChangeSelection = (keys: Selection) => { /* ... */ }; // βœ… handleVerbNoun pattern + const inviteMutation = api.useMutation("post", "/api/account-management/users/invite", { + onSuccess: () => { // βœ… Show toast in onSuccess (not useEffect) + setIsFormDirty(false); + toastQueue.add({ title: t`Success`, description: t`User invited`, variant: "success" }); + onOpenChange(false); + } + }); + + const handleCloseComplete = () => setIsFormDirty(false); + const handleCancel = () => { setIsFormDirty(false); onOpenChange(false); }; // βœ… Clear state + close (bypasses warning) return ( - // βœ… Prevent dismiss during pending + // βœ… Use dialog width classes (not max-w-lg) {({ close }) => ( // βœ… Dialog render prop provides close function <> - // βœ… Close button pattern (onClick is exception) + // βœ… X uses close (shows warning if dirty) Select users - - - {activeUsers.map((user) => ( - - {`${user.firstName} ${user.lastName}`} - - ))} - - - - - +
+ + setIsFormDirty(true)} /> + + + + + +
)}
-
+ ); } @@ -152,11 +168,19 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { const [filteredUsers, setFilteredUsers] = useState([]); // ❌ State for derived values const [isAdmin, setIsAdmin] = useState(false); // ❌ Duplicate state that can be calculated + const inviteMutation = api.useMutation("post", "/api/users/invite"); + useEffect(() => { // ❌ useEffect for calculations - compute inline instead setFilteredUsers(users.filter(u => u.isActive)); setIsAdmin(users.some(u => u.id === selectedId && u.role === "admin")); // ❌ Hardcode strings - use API contract types }, [users, selectedId]); + useEffect(() => { // ❌ useEffect watching isSuccess causes toast timing issues + if (inviteMutation.isSuccess) { + toastQueue.add({ title: "Success", variant: "success" }); + } + }, [inviteMutation.isSuccess]); + const getDisplayName = useCallback((user) => { // ❌ Premature useCallback without performance need return `${user.firstName} ${user.lastName}`; }, []); @@ -166,17 +190,25 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { return ( // ❌ Missing isDismissable={!isPending} // ❌ max-w-lg (use w-dialog-md), hardcoded colors (use bg-background) -

User Mgmt

// ❌ Native

(use Heading), acronym "Mgmt", missing -
    // ❌ Native
      - use ListBox - {filteredUsers.map(user => ( -
    • handleSelect(user.id)}> // ❌ Native
    • , onClick (use onAction) - // ❌ Native - use Avatar - {user.email} // ❌ text-sm with Text causes blur - {getDisplayName(user)} -
    • - ))} -
    - // ❌ Missing isDisabled/isPending, missing + {({ close }) => ( // ❌ Both X and Cancel use close (Cancel should use handleCancel) + <> + +

    User Mgmt

    // ❌ Native

    (use Heading), acronym "Mgmt", missing +
      // ❌ Native
        - use ListBox + {filteredUsers.map(user => ( +
      • handleSelect(user.id)}> // ❌ Native
      • , onClick (use onAction) + // ❌ Native - use Avatar + {user.email} // ❌ text-sm with Text causes blur + {getDisplayName(user)} +
      • + ))} +
      + // ❌ Cancel uses close (shows unwanted warning) + + + )}

); diff --git a/.github/copilot/rules/frontend/frontend.md b/.github/copilot/rules/frontend/frontend.md index ab62aa16e..d5ab5ec8c 100644 --- a/.github/copilot/rules/frontend/frontend.md +++ b/.github/copilot/rules/frontend/frontend.md @@ -69,6 +69,7 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - **Errors are handled globally**β€”`shared-webapp/infrastructure/http/errorHandler.ts` automatically shows toast notifications with the server's error message (don't manually show toasts for errors) - **Validation errors**: Pass to forms via `validationErrors={mutation.error?.errors}` - **`onError` is for UI cleanup only** (resetting loading states, closing dialogs), not for showing errors + - **Toast notifications**: Show success toasts in mutation `onSuccess` callbacks, not in `useEffect` watching `isSuccess` (avoids React effect scheduling delays) 4. Responsive design utilities: - Use `useViewportResize()` hook to detect mobile viewport (returns `true` when mobile) @@ -87,16 +88,21 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - `z-[200]`: Mobile full-screen menus - Note: Dropdowns, tooltips, and popovers use React Aria's overlay system which manages stacking relative to their context -6. Always follow these steps when implementing changes: +6. DirtyModal close handlers: + - **X button**: Use Dialog's `close` from render prop (shows unsaved warning if dirty) + - **Cancel button**: Use `handleCancel` that clears state and closes immediately (bypasses warning) + - Always clear dirty state in `onSuccess` and `onCloseComplete` + +7. Always follow these steps when implementing changes: - Consult relevant rule files and list which ones guided your implementation - Search the codebase for similar code before implementing new code - Reference existing implementations to maintain consistency -7. Build and format your changes: +8. Build and format your changes: - After each minor change, use the **execute MCP tool** with `command: "build"` for frontend - This ensures consistent code style across the codebase -8. Verify your changes: +9. Verify your changes: - When a feature is complete, run these MCP tools for frontend in sequence: **build**, **format**, **inspect** - **ALL inspect findings are blocking** - CI pipeline fails on any result marked "Issues found" - Severity level (note/warning/error) is irrelevant - fix all findings before proceeding @@ -106,39 +112,49 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v ```tsx // βœ… DO: Correct patterns -export function UserPicker({ isOpen, isPending, onOpenChange }: UserPickerProps) { +export function UserPicker({ isOpen, onOpenChange }: UserPickerProps) { + const [isFormDirty, setIsFormDirty] = useState(false); const { data } = api.useQuery("get", "/api/account-management/users", { enabled: isOpen }); const activeUsers = (data?.users ?? []).filter((u) => u.isActive); // βœ… Compute derived values inline - const handleChangeSelection = (keys: Selection) => { /* ... */ }; // βœ… handleVerbNoun pattern + const inviteMutation = api.useMutation("post", "/api/account-management/users/invite", { + onSuccess: () => { // βœ… Show toast in onSuccess (not useEffect) + setIsFormDirty(false); + toastQueue.add({ title: t`Success`, description: t`User invited`, variant: "success" }); + onOpenChange(false); + } + }); + + const handleCloseComplete = () => setIsFormDirty(false); + const handleCancel = () => { setIsFormDirty(false); onOpenChange(false); }; // βœ… Clear state + close (bypasses warning) return ( - // βœ… Prevent dismiss during pending + // βœ… Use dialog width classes (not max-w-lg) {({ close }) => ( // βœ… Dialog render prop provides close function <> - // βœ… Close button pattern (onClick is exception) + // βœ… X uses close (shows warning if dirty) Select users - - - {activeUsers.map((user) => ( - - {`${user.firstName} ${user.lastName}`} - - ))} - - - - - +
+ + setIsFormDirty(true)} /> + + + + + +
)}
-
+ ); } @@ -147,11 +163,19 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { const [filteredUsers, setFilteredUsers] = useState([]); // ❌ State for derived values const [isAdmin, setIsAdmin] = useState(false); // ❌ Duplicate state that can be calculated + const inviteMutation = api.useMutation("post", "/api/users/invite"); + useEffect(() => { // ❌ useEffect for calculations - compute inline instead setFilteredUsers(users.filter(u => u.isActive)); setIsAdmin(users.some(u => u.id === selectedId && u.role === "admin")); // ❌ Hardcode strings - use API contract types }, [users, selectedId]); + useEffect(() => { // ❌ useEffect watching isSuccess causes toast timing issues + if (inviteMutation.isSuccess) { + toastQueue.add({ title: "Success", variant: "success" }); + } + }, [inviteMutation.isSuccess]); + const getDisplayName = useCallback((user) => { // ❌ Premature useCallback without performance need return `${user.firstName} ${user.lastName}`; }, []); @@ -161,17 +185,25 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { return ( // ❌ Missing isDismissable={!isPending} // ❌ max-w-lg (use w-dialog-md), hardcoded colors (use bg-background) -

User Mgmt

// ❌ Native

(use Heading), acronym "Mgmt", missing -
    // ❌ Native
      - use ListBox - {filteredUsers.map(user => ( -
    • handleSelect(user.id)}> // ❌ Native
    • , onClick (use onAction) - // ❌ Native - use Avatar - {user.email} // ❌ text-sm with Text causes blur - {getDisplayName(user)} -
    • - ))} -
    - // ❌ Missing isDisabled/isPending, missing + {({ close }) => ( // ❌ Both X and Cancel use close (Cancel should use handleCancel) + <> + +

    User Mgmt

    // ❌ Native

    (use Heading), acronym "Mgmt", missing +
      // ❌ Native
        - use ListBox + {filteredUsers.map(user => ( +
      • handleSelect(user.id)}> // ❌ Native
      • , onClick (use onAction) + // ❌ Native - use Avatar + {user.email} // ❌ text-sm with Text causes blur + {getDisplayName(user)} +
      • + ))} +
      + // ❌ Cancel uses close (shows unwanted warning) + + + )}

); diff --git a/.windsurf/rules/frontend/frontend.md b/.windsurf/rules/frontend/frontend.md index 766ef2ff0..5b9fa50e0 100644 --- a/.windsurf/rules/frontend/frontend.md +++ b/.windsurf/rules/frontend/frontend.md @@ -75,6 +75,7 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - **Errors are handled globally**β€”`shared-webapp/infrastructure/http/errorHandler.ts` automatically shows toast notifications with the server's error message (don't manually show toasts for errors) - **Validation errors**: Pass to forms via `validationErrors={mutation.error?.errors}` - **`onError` is for UI cleanup only** (resetting loading states, closing dialogs), not for showing errors + - **Toast notifications**: Show success toasts in mutation `onSuccess` callbacks, not in `useEffect` watching `isSuccess` (avoids React effect scheduling delays) 4. Responsive design utilities: - Use `useViewportResize()` hook to detect mobile viewport (returns `true` when mobile) @@ -93,16 +94,21 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v - `z-[200]`: Mobile full-screen menus - Note: Dropdowns, tooltips, and popovers use React Aria's overlay system which manages stacking relative to their context -6. Always follow these steps when implementing changes: +6. DirtyModal close handlers: + - **X button**: Use Dialog's `close` from render prop (shows unsaved warning if dirty) + - **Cancel button**: Use `handleCancel` that clears state and closes immediately (bypasses warning) + - Always clear dirty state in `onSuccess` and `onCloseComplete` + +7. Always follow these steps when implementing changes: - Consult relevant rule files and list which ones guided your implementation - Search the codebase for similar code before implementing new code - Reference existing implementations to maintain consistency -7. Build and format your changes: +8. Build and format your changes: - After each minor change, use the **execute MCP tool** with `command: "build"` for frontend - This ensures consistent code style across the codebase -8. Verify your changes: +9. Verify your changes: - When a feature is complete, run these MCP tools for frontend in sequence: **build**, **format**, **inspect** - **ALL inspect findings are blocking** - CI pipeline fails on any result marked "Issues found" - Severity level (note/warning/error) is irrelevant - fix all findings before proceeding @@ -112,39 +118,49 @@ Use browser MCP tools to test at `https://localhost:9000`. Use `UNLOCK` as OTP v ```tsx // βœ… DO: Correct patterns -export function UserPicker({ isOpen, isPending, onOpenChange }: UserPickerProps) { +export function UserPicker({ isOpen, onOpenChange }: UserPickerProps) { + const [isFormDirty, setIsFormDirty] = useState(false); const { data } = api.useQuery("get", "/api/account-management/users", { enabled: isOpen }); const activeUsers = (data?.users ?? []).filter((u) => u.isActive); // βœ… Compute derived values inline - const handleChangeSelection = (keys: Selection) => { /* ... */ }; // βœ… handleVerbNoun pattern + const inviteMutation = api.useMutation("post", "/api/account-management/users/invite", { + onSuccess: () => { // βœ… Show toast in onSuccess (not useEffect) + setIsFormDirty(false); + toastQueue.add({ title: t`Success`, description: t`User invited`, variant: "success" }); + onOpenChange(false); + } + }); + + const handleCloseComplete = () => setIsFormDirty(false); + const handleCancel = () => { setIsFormDirty(false); onOpenChange(false); }; // βœ… Clear state + close (bypasses warning) return ( - // βœ… Prevent dismiss during pending + // βœ… Use dialog width classes (not max-w-lg) {({ close }) => ( // βœ… Dialog render prop provides close function <> - // βœ… Close button pattern (onClick is exception) + // βœ… X uses close (shows warning if dirty) Select users - - - {activeUsers.map((user) => ( - - {`${user.firstName} ${user.lastName}`} - - ))} - - - - - +
+ + setIsFormDirty(true)} /> + + + + + +
)}
-
+ ); } @@ -153,11 +169,19 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { const [filteredUsers, setFilteredUsers] = useState([]); // ❌ State for derived values const [isAdmin, setIsAdmin] = useState(false); // ❌ Duplicate state that can be calculated + const inviteMutation = api.useMutation("post", "/api/users/invite"); + useEffect(() => { // ❌ useEffect for calculations - compute inline instead setFilteredUsers(users.filter(u => u.isActive)); setIsAdmin(users.some(u => u.id === selectedId && u.role === "admin")); // ❌ Hardcode strings - use API contract types }, [users, selectedId]); + useEffect(() => { // ❌ useEffect watching isSuccess causes toast timing issues + if (inviteMutation.isSuccess) { + toastQueue.add({ title: "Success", variant: "success" }); + } + }, [inviteMutation.isSuccess]); + const getDisplayName = useCallback((user) => { // ❌ Premature useCallback without performance need return `${user.firstName} ${user.lastName}`; }, []); @@ -167,17 +191,25 @@ function BadUserDialog({ users, selectedId, isOpen, onClose }) { return ( // ❌ Missing isDismissable={!isPending} // ❌ max-w-lg (use w-dialog-md), hardcoded colors (use bg-background) -

User Mgmt

// ❌ Native

(use Heading), acronym "Mgmt", missing -
    // ❌ Native
      - use ListBox - {filteredUsers.map(user => ( -
    • handleSelect(user.id)}> // ❌ Native
    • , onClick (use onAction) - // ❌ Native - use Avatar - {user.email} // ❌ text-sm with Text causes blur - {getDisplayName(user)} -
    • - ))} -
    - // ❌ Missing isDisabled/isPending, missing + {({ close }) => ( // ❌ Both X and Cancel use close (Cancel should use handleCancel) + <> + +

    User Mgmt

    // ❌ Native

    (use Heading), acronym "Mgmt", missing +
      // ❌ Native
        - use ListBox + {filteredUsers.map(user => ( +
      • handleSelect(user.id)}> // ❌ Native
      • , onClick (use onAction) + // ❌ Native - use Avatar + {user.email} // ❌ text-sm with Text causes blur + {getDisplayName(user)} +
      • + ))} +
      + // ❌ Cancel uses close (shows unwanted warning) + + + )}

); From 4c6734e81423cea4541f0d75032f98d06e690b9f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 18:04:14 +0100 Subject: [PATCH 8/9] Add pending state text feedback to invite user submit button --- .../WebApp/routes/admin/users/-components/InviteUserDialog.tsx | 2 +- .../WebApp/shared/translations/locale/da-DK.po | 3 +++ .../WebApp/shared/translations/locale/en-US.po | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx index bfb7168f7..df18a882a 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx @@ -86,7 +86,7 @@ export default function InviteUserDialog({ isOpen, onOpenChange }: ReadonlyCancel diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index cd0bf35d7..643a68345 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -702,6 +702,9 @@ msgstr "Send invitation" msgid "Sending verification code..." msgstr "Sender bekræftelseskode..." +msgid "Sending..." +msgstr "Sender..." + msgid "Session ended" msgstr "Session afsluttet" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index bd527fcee..982d2becd 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -702,6 +702,9 @@ msgstr "Send invite" msgid "Sending verification code..." msgstr "Sending verification code..." +msgid "Sending..." +msgstr "Sending..." + msgid "Session ended" msgstr "Session ended" From a0a4a890b57f9afa578a6d6e3f81216fe60f54f5 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 14 Jan 2026 19:37:35 +0100 Subject: [PATCH 9/9] Update QA review workflow to run full regression test suite before commit --- .../process/review-end-to-end-tests.md | 21 +++++++++++++------ .../process/review-end-to-end-tests.md | 21 +++++++++++++------ .../process/review-end-to-end-tests.mdc | 21 +++++++++++++------ .../process/review-end-to-end-tests.md | 21 +++++++++++++------ .../process/review-end-to-end-tests.md | 21 +++++++++++++------ 5 files changed, 75 insertions(+), 30 deletions(-) diff --git a/.agent/workflows/process/review-end-to-end-tests.md b/.agent/workflows/process/review-end-to-end-tests.md index 77fb68531..d247222bd 100644 --- a/.agent/workflows/process/review-end-to-end-tests.md +++ b/.agent/workflows/process/review-end-to-end-tests.md @@ -43,11 +43,12 @@ You are reviewing: **{{{title}}}** { "todos": [ {"content": "Read [feature] and [task] to understand requirements", "status": "pending", "activeForm": "Reading feature and task"}, - {"content": "Run e2e tests and verify ALL pass with zero tolerance", "status": "pending", "activeForm": "Running E2E tests"}, + {"content": "Run feature-specific e2e tests", "status": "pending", "activeForm": "Running feature E2E tests"}, {"content": "Review test file structure and organization", "status": "pending", "activeForm": "Reviewing test structure"}, {"content": "Review each test step for correct patterns", "status": "pending", "activeForm": "Reviewing test steps"}, {"content": "Review test efficiency and speed", "status": "pending", "activeForm": "Reviewing test efficiency"}, {"content": "Make binary decision (approve or reject)", "status": "pending", "activeForm": "Making decision"}, + {"content": "If approved, run full regression test suite", "status": "pending", "activeForm": "Running full regression tests"}, {"content": "If approved, commit changes", "status": "pending", "activeForm": "Committing if approved"}, {"content": "Update [task] status to [Completed] or [Active]", "status": "pending", "activeForm": "Updating task status"}, {"content": "MANDATORY: Call CompleteWork", "status": "pending", "activeForm": "Calling CompleteWork"} @@ -76,13 +77,13 @@ You are reviewing: **{{{title}}}** - Read [End-to-End Tests](/.agent/rules/end-to-end-tests/end-to-end-tests.md) - Ensure engineer followed all patterns -**STEP 2**: Run e2e tests and verify ALL pass with zero tolerance +**STEP 2**: Run feature-specific e2e tests first **If tests require backend changes, run the run tool first**: - Use **run MCP tool** to restart server and run migrations - The tool starts .NET Aspire at https://localhost:9000 -**Run E2E tests**: +**Run feature-specific E2E tests**: - Use **end-to-end MCP tool** to run tests: `end-to-end(searchTerms=["feature-name"])` - **ALL tests MUST pass with ZERO failures to approve** - **Verify ZERO console errors** during test execution @@ -150,7 +151,15 @@ You are reviewing: **{{{title}}}** **When rejecting:** Do full review first, then reject with ALL issues listed (avoid multiple rounds). -**STEP 7**: If approved, commit changes +**STEP 7**: If approved, run full regression test suite + +**Before committing, run all e2e tests to ensure no regressions:** +- Use **end-to-end MCP tool** WITHOUT searchTerms: `end-to-end()` +- This runs the complete test suite across all browsers +- **ALL tests MUST pass with ZERO failures** +- If ANY test fails: REJECT (do not commit) + +**STEP 8**: Commit changes 1. Stage test files: `git add ` for each test file 2. Commit: One line, imperative form, no description, no co-author @@ -158,7 +167,7 @@ You are reviewing: **{{{title}}}** Don't use `git add -A` or `git add .` -**STEP 8**: Update [task] status to [Completed] or [Active] +**STEP 9**: Update [task] status to [Completed] or [Active] **If `featureId` is NOT "ad-hoc" (regular task from a feature):** - If APPROVED: Update [task] status to [Completed]. @@ -167,7 +176,7 @@ Don't use `git add -A` or `git add .` **If `featureId` is "ad-hoc" (ad-hoc work):** - Skip [PRODUCT_MANAGEMENT_TOOL] status updates. -**STEP 9**: Call CompleteWork +**STEP 10**: Call CompleteWork **Call MCP CompleteWork tool**: - `mode`: "review" diff --git a/.claude/commands/process/review-end-to-end-tests.md b/.claude/commands/process/review-end-to-end-tests.md index 4f90b3918..f301670d3 100644 --- a/.claude/commands/process/review-end-to-end-tests.md +++ b/.claude/commands/process/review-end-to-end-tests.md @@ -48,11 +48,12 @@ You are reviewing: **{{{title}}}** { "todos": [ {"content": "Read [feature] and [task] to understand requirements", "status": "pending", "activeForm": "Reading feature and task"}, - {"content": "Run e2e tests and verify ALL pass with zero tolerance", "status": "pending", "activeForm": "Running E2E tests"}, + {"content": "Run feature-specific e2e tests", "status": "pending", "activeForm": "Running feature E2E tests"}, {"content": "Review test file structure and organization", "status": "pending", "activeForm": "Reviewing test structure"}, {"content": "Review each test step for correct patterns", "status": "pending", "activeForm": "Reviewing test steps"}, {"content": "Review test efficiency and speed", "status": "pending", "activeForm": "Reviewing test efficiency"}, {"content": "Make binary decision (approve or reject)", "status": "pending", "activeForm": "Making decision"}, + {"content": "If approved, run full regression test suite", "status": "pending", "activeForm": "Running full regression tests"}, {"content": "If approved, commit changes", "status": "pending", "activeForm": "Committing if approved"}, {"content": "Update [task] status to [Completed] or [Active]", "status": "pending", "activeForm": "Updating task status"}, {"content": "MANDATORY: Call CompleteWork", "status": "pending", "activeForm": "Calling CompleteWork"} @@ -81,13 +82,13 @@ You are reviewing: **{{{title}}}** - Read [End-to-End Tests](/.claude/rules/end-to-end-tests/end-to-end-tests.md) - Ensure engineer followed all patterns -**STEP 2**: Run e2e tests and verify ALL pass with zero tolerance +**STEP 2**: Run feature-specific e2e tests first **If tests require backend changes, run the run tool first**: - Use **run MCP tool** to restart server and run migrations - The tool starts .NET Aspire at https://localhost:9000 -**Run E2E tests**: +**Run feature-specific E2E tests**: - Use **end-to-end MCP tool** to run tests: `end-to-end(searchTerms=["feature-name"])` - **ALL tests MUST pass with ZERO failures to approve** - **Verify ZERO console errors** during test execution @@ -155,7 +156,15 @@ You are reviewing: **{{{title}}}** **When rejecting:** Do full review first, then reject with ALL issues listed (avoid multiple rounds). -**STEP 7**: If approved, commit changes +**STEP 7**: If approved, run full regression test suite + +**Before committing, run all e2e tests to ensure no regressions:** +- Use **end-to-end MCP tool** WITHOUT searchTerms: `end-to-end()` +- This runs the complete test suite across all browsers +- **ALL tests MUST pass with ZERO failures** +- If ANY test fails: REJECT (do not commit) + +**STEP 8**: Commit changes 1. Stage test files: `git add ` for each test file 2. Commit: One line, imperative form, no description, no co-author @@ -163,7 +172,7 @@ You are reviewing: **{{{title}}}** Don't use `git add -A` or `git add .` -**STEP 8**: Update [task] status to [Completed] or [Active] +**STEP 9**: Update [task] status to [Completed] or [Active] **If `featureId` is NOT "ad-hoc" (regular task from a feature):** - If APPROVED: Update [task] status to [Completed]. @@ -172,7 +181,7 @@ Don't use `git add -A` or `git add .` **If `featureId` is "ad-hoc" (ad-hoc work):** - Skip [PRODUCT_MANAGEMENT_TOOL] status updates. -**STEP 9**: Call CompleteWork +**STEP 10**: Call CompleteWork **Call MCP CompleteWork tool**: - `mode`: "review" diff --git a/.cursor/rules/workflows/process/review-end-to-end-tests.mdc b/.cursor/rules/workflows/process/review-end-to-end-tests.mdc index 39488c167..c8b0ad259 100644 --- a/.cursor/rules/workflows/process/review-end-to-end-tests.mdc +++ b/.cursor/rules/workflows/process/review-end-to-end-tests.mdc @@ -45,11 +45,12 @@ You are reviewing: **{{{title}}}** { "todos": [ {"content": "Read [feature] and [task] to understand requirements", "status": "pending", "activeForm": "Reading feature and task"}, - {"content": "Run e2e tests and verify ALL pass with zero tolerance", "status": "pending", "activeForm": "Running E2E tests"}, + {"content": "Run feature-specific e2e tests", "status": "pending", "activeForm": "Running feature E2E tests"}, {"content": "Review test file structure and organization", "status": "pending", "activeForm": "Reviewing test structure"}, {"content": "Review each test step for correct patterns", "status": "pending", "activeForm": "Reviewing test steps"}, {"content": "Review test efficiency and speed", "status": "pending", "activeForm": "Reviewing test efficiency"}, {"content": "Make binary decision (approve or reject)", "status": "pending", "activeForm": "Making decision"}, + {"content": "If approved, run full regression test suite", "status": "pending", "activeForm": "Running full regression tests"}, {"content": "If approved, commit changes", "status": "pending", "activeForm": "Committing if approved"}, {"content": "Update [task] status to [Completed] or [Active]", "status": "pending", "activeForm": "Updating task status"}, {"content": "MANDATORY: Call CompleteWork", "status": "pending", "activeForm": "Calling CompleteWork"} @@ -78,13 +79,13 @@ You are reviewing: **{{{title}}}** - Read [End-to-End Tests](mdc:.cursor/rules/end-to-end-tests/end-to-end-tests.mdc) - Ensure engineer followed all patterns -**STEP 2**: Run e2e tests and verify ALL pass with zero tolerance +**STEP 2**: Run feature-specific e2e tests first **If tests require backend changes, run the run tool first**: - Use **run MCP tool** to restart server and run migrations - The tool starts .NET Aspire at https://localhost:9000 -**Run E2E tests**: +**Run feature-specific E2E tests**: - Use **end-to-end MCP tool** to run tests: `end-to-end(searchTerms=["feature-name"])` - **ALL tests MUST pass with ZERO failures to approve** - **Verify ZERO console errors** during test execution @@ -152,7 +153,15 @@ You are reviewing: **{{{title}}}** **When rejecting:** Do full review first, then reject with ALL issues listed (avoid multiple rounds). -**STEP 7**: If approved, commit changes +**STEP 7**: If approved, run full regression test suite + +**Before committing, run all e2e tests to ensure no regressions:** +- Use **end-to-end MCP tool** WITHOUT searchTerms: `end-to-end()` +- This runs the complete test suite across all browsers +- **ALL tests MUST pass with ZERO failures** +- If ANY test fails: REJECT (do not commit) + +**STEP 8**: Commit changes 1. Stage test files: `git add ` for each test file 2. Commit: One line, imperative form, no description, no co-author @@ -160,7 +169,7 @@ You are reviewing: **{{{title}}}** Don't use `git add -A` or `git add .` -**STEP 8**: Update [task] status to [Completed] or [Active] +**STEP 9**: Update [task] status to [Completed] or [Active] **If `featureId` is NOT "ad-hoc" (regular task from a feature):** - If APPROVED: Update [task] status to [Completed]. @@ -169,7 +178,7 @@ Don't use `git add -A` or `git add .` **If `featureId` is "ad-hoc" (ad-hoc work):** - Skip [PRODUCT_MANAGEMENT_TOOL] status updates. -**STEP 9**: Call CompleteWork +**STEP 10**: Call CompleteWork **Call MCP CompleteWork tool**: - `mode`: "review" diff --git a/.github/copilot/workflows/process/review-end-to-end-tests.md b/.github/copilot/workflows/process/review-end-to-end-tests.md index 6dd992e7a..08c43e503 100644 --- a/.github/copilot/workflows/process/review-end-to-end-tests.md +++ b/.github/copilot/workflows/process/review-end-to-end-tests.md @@ -40,11 +40,12 @@ You are reviewing: **{{{title}}}** { "todos": [ {"content": "Read [feature] and [task] to understand requirements", "status": "pending", "activeForm": "Reading feature and task"}, - {"content": "Run e2e tests and verify ALL pass with zero tolerance", "status": "pending", "activeForm": "Running E2E tests"}, + {"content": "Run feature-specific e2e tests", "status": "pending", "activeForm": "Running feature E2E tests"}, {"content": "Review test file structure and organization", "status": "pending", "activeForm": "Reviewing test structure"}, {"content": "Review each test step for correct patterns", "status": "pending", "activeForm": "Reviewing test steps"}, {"content": "Review test efficiency and speed", "status": "pending", "activeForm": "Reviewing test efficiency"}, {"content": "Make binary decision (approve or reject)", "status": "pending", "activeForm": "Making decision"}, + {"content": "If approved, run full regression test suite", "status": "pending", "activeForm": "Running full regression tests"}, {"content": "If approved, commit changes", "status": "pending", "activeForm": "Committing if approved"}, {"content": "Update [task] status to [Completed] or [Active]", "status": "pending", "activeForm": "Updating task status"}, {"content": "MANDATORY: Call CompleteWork", "status": "pending", "activeForm": "Calling CompleteWork"} @@ -73,13 +74,13 @@ You are reviewing: **{{{title}}}** - Read [End-to-End Tests](/.github/copilot/rules/end-to-end-tests/end-to-end-tests.md) - Ensure engineer followed all patterns -**STEP 2**: Run e2e tests and verify ALL pass with zero tolerance +**STEP 2**: Run feature-specific e2e tests first **If tests require backend changes, run the run tool first**: - Use **run MCP tool** to restart server and run migrations - The tool starts .NET Aspire at https://localhost:9000 -**Run E2E tests**: +**Run feature-specific E2E tests**: - Use **end-to-end MCP tool** to run tests: `end-to-end(searchTerms=["feature-name"])` - **ALL tests MUST pass with ZERO failures to approve** - **Verify ZERO console errors** during test execution @@ -147,7 +148,15 @@ You are reviewing: **{{{title}}}** **When rejecting:** Do full review first, then reject with ALL issues listed (avoid multiple rounds). -**STEP 7**: If approved, commit changes +**STEP 7**: If approved, run full regression test suite + +**Before committing, run all e2e tests to ensure no regressions:** +- Use **end-to-end MCP tool** WITHOUT searchTerms: `end-to-end()` +- This runs the complete test suite across all browsers +- **ALL tests MUST pass with ZERO failures** +- If ANY test fails: REJECT (do not commit) + +**STEP 8**: Commit changes 1. Stage test files: `git add ` for each test file 2. Commit: One line, imperative form, no description, no co-author @@ -155,7 +164,7 @@ You are reviewing: **{{{title}}}** Don't use `git add -A` or `git add .` -**STEP 8**: Update [task] status to [Completed] or [Active] +**STEP 9**: Update [task] status to [Completed] or [Active] **If `featureId` is NOT "ad-hoc" (regular task from a feature):** - If APPROVED: Update [task] status to [Completed]. @@ -164,7 +173,7 @@ Don't use `git add -A` or `git add .` **If `featureId` is "ad-hoc" (ad-hoc work):** - Skip [PRODUCT_MANAGEMENT_TOOL] status updates. -**STEP 9**: Call CompleteWork +**STEP 10**: Call CompleteWork **Call MCP CompleteWork tool**: - `mode`: "review" diff --git a/.windsurf/workflows/process/review-end-to-end-tests.md b/.windsurf/workflows/process/review-end-to-end-tests.md index 49954bfb5..7b08250fc 100644 --- a/.windsurf/workflows/process/review-end-to-end-tests.md +++ b/.windsurf/workflows/process/review-end-to-end-tests.md @@ -45,11 +45,12 @@ You are reviewing: **{{{title}}}** { "todos": [ {"content": "Read [feature] and [task] to understand requirements", "status": "pending", "activeForm": "Reading feature and task"}, - {"content": "Run e2e tests and verify ALL pass with zero tolerance", "status": "pending", "activeForm": "Running E2E tests"}, + {"content": "Run feature-specific e2e tests", "status": "pending", "activeForm": "Running feature E2E tests"}, {"content": "Review test file structure and organization", "status": "pending", "activeForm": "Reviewing test structure"}, {"content": "Review each test step for correct patterns", "status": "pending", "activeForm": "Reviewing test steps"}, {"content": "Review test efficiency and speed", "status": "pending", "activeForm": "Reviewing test efficiency"}, {"content": "Make binary decision (approve or reject)", "status": "pending", "activeForm": "Making decision"}, + {"content": "If approved, run full regression test suite", "status": "pending", "activeForm": "Running full regression tests"}, {"content": "If approved, commit changes", "status": "pending", "activeForm": "Committing if approved"}, {"content": "Update [task] status to [Completed] or [Active]", "status": "pending", "activeForm": "Updating task status"}, {"content": "MANDATORY: Call CompleteWork", "status": "pending", "activeForm": "Calling CompleteWork"} @@ -78,13 +79,13 @@ You are reviewing: **{{{title}}}** - Read [End-to-End Tests](/.windsurf/rules/end-to-end-tests/end-to-end-tests.md) - Ensure engineer followed all patterns -**STEP 2**: Run e2e tests and verify ALL pass with zero tolerance +**STEP 2**: Run feature-specific e2e tests first **If tests require backend changes, run the run tool first**: - Use **run MCP tool** to restart server and run migrations - The tool starts .NET Aspire at https://localhost:9000 -**Run E2E tests**: +**Run feature-specific E2E tests**: - Use **end-to-end MCP tool** to run tests: `end-to-end(searchTerms=["feature-name"])` - **ALL tests MUST pass with ZERO failures to approve** - **Verify ZERO console errors** during test execution @@ -152,7 +153,15 @@ You are reviewing: **{{{title}}}** **When rejecting:** Do full review first, then reject with ALL issues listed (avoid multiple rounds). -**STEP 7**: If approved, commit changes +**STEP 7**: If approved, run full regression test suite + +**Before committing, run all e2e tests to ensure no regressions:** +- Use **end-to-end MCP tool** WITHOUT searchTerms: `end-to-end()` +- This runs the complete test suite across all browsers +- **ALL tests MUST pass with ZERO failures** +- If ANY test fails: REJECT (do not commit) + +**STEP 8**: Commit changes 1. Stage test files: `git add ` for each test file 2. Commit: One line, imperative form, no description, no co-author @@ -160,7 +169,7 @@ You are reviewing: **{{{title}}}** Don't use `git add -A` or `git add .` -**STEP 8**: Update [task] status to [Completed] or [Active] +**STEP 9**: Update [task] status to [Completed] or [Active] **If `featureId` is NOT "ad-hoc" (regular task from a feature):** - If APPROVED: Update [task] status to [Completed]. @@ -169,7 +178,7 @@ Don't use `git add -A` or `git add .` **If `featureId` is "ad-hoc" (ad-hoc work):** - Skip [PRODUCT_MANAGEMENT_TOOL] status updates. -**STEP 9**: Call CompleteWork +**STEP 10**: Call CompleteWork **Call MCP CompleteWork tool**: - `mode`: "review"