From 5407b92f564fccfa3e344243d73f8f40d71b1090 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Fri, 17 Jul 2026 16:27:41 +0200 Subject: [PATCH 1/2] try and make tests more stable * State leakage/coupling: reduced in groups/SAML flows by scoped test data and improved targeting. * Selector brittleness: reduced via table + alert helper hardening. * Timing races: reduced via deterministic readiness assertions and switch handling. * Environment assumptions: addressed via env-configurable server/auth and OID4VCI feature gating. fixes: #50994 Signed-off-by: Erik Jan de Wit --- js/apps/admin-ui/test/autentication/flow.ts | 136 +++++++++++++++- .../admin-ui/test/autentication/flows.spec.ts | 22 +-- .../client-scope/oid4vci-client-scope.spec.ts | 127 ++++++++------- .../assign-oid4vci-client-scope.spec.ts | 51 ++---- .../clients/assign-oid4vci-client-scope.ts | 77 +++++++++ js/apps/admin-ui/test/clients/saml.spec.ts | 18 ++- js/apps/admin-ui/test/clients/saml.ts | 22 ++- js/apps/admin-ui/test/clients/scope.ts | 41 ++++- js/apps/admin-ui/test/groups/list.spec.ts | 46 ++++-- .../identity-providers/default-trust.spec.ts | 4 +- .../admin-ui/test/identity-providers/main.ts | 2 +- .../saml-signature-defaults.spec.ts | 4 +- .../admin-ui/test/identity-providers/saml.ts | 35 ++-- js/apps/admin-ui/test/masthead/realm.spec.ts | 4 +- .../test/realm-settings/general.spec.ts | 4 +- .../test/realm-settings/login.spec.ts | 6 +- js/apps/admin-ui/test/realm-settings/login.ts | 26 ++- .../test/realm-settings/userprofile.spec.ts | 19 ++- .../test/realm-settings/userprofile.ts | 29 +++- .../test/user-federation/kerberos.spec.ts | 4 +- js/apps/admin-ui/test/utils/AdminClient.ts | 48 ++++-- js/apps/admin-ui/test/utils/constants.ts | 11 +- js/apps/admin-ui/test/utils/form.ts | 125 ++++++++++++++- js/apps/admin-ui/test/utils/masthead.ts | 6 +- js/apps/admin-ui/test/utils/table.ts | 151 +++++++++++++++--- 25 files changed, 806 insertions(+), 212 deletions(-) create mode 100644 js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.ts diff --git a/js/apps/admin-ui/test/autentication/flow.ts b/js/apps/admin-ui/test/autentication/flow.ts index 29882368d1e8..369746c759bc 100644 --- a/js/apps/admin-ui/test/autentication/flow.ts +++ b/js/apps/admin-ui/test/autentication/flow.ts @@ -1,4 +1,4 @@ -import { type Page, expect } from "@playwright/test"; +import { type Locator, type Page, expect } from "@playwright/test"; import { selectItem } from "../utils/form.ts"; import { confirmModal } from "../utils/modal.ts"; @@ -145,3 +145,137 @@ export async function fillCreateForm( await selectItem(page, page.getByLabel("Flow type"), type); await page.getByTestId("create").click(); } + +export async function dragExecutionAboveExecution( + page: Page, + sourceExecution: string, + targetExecution: string, +) { + const treeGrid = page.getByRole("treegrid", { name: "Flows" }); + const sourceRow = treeGrid + .getByRole("row") + .filter({ hasText: sourceExecution }) + .first(); + const targetRow = treeGrid + .getByRole("row") + .filter({ hasText: targetExecution }) + .first(); + const getDragHandle = (row: Locator) => + row.getByRole("button", { name: /draggable row/i }).first(); + const sourceHandle = getDragHandle(sourceRow); + const targetHandle = getDragHandle(targetRow); + + await expect(sourceRow).toBeVisible({ timeout: 5_000 }); + await expect(targetRow).toBeVisible({ timeout: 5_000 }); + await expect(sourceHandle).toBeVisible({ timeout: 5_000 }); + await expect(targetHandle).toBeVisible({ timeout: 5_000 }); + await sourceHandle.scrollIntoViewIfNeeded(); + await targetHandle.scrollIntoViewIfNeeded(); + + const hasMoved = async () => { + const rows = await treeGrid.getByRole("row").allInnerTexts(); + const sourceIndex = rows.findIndex((row) => row.includes(sourceExecution)); + const targetIndex = rows.findIndex((row) => row.includes(targetExecution)); + return ( + sourceIndex !== -1 && targetIndex !== -1 && sourceIndex < targetIndex + ); + }; + + const waitForMove = async () => { + try { + await expect.poll(hasMoved, { timeout: 4_000 }).toBe(true); + return true; + } catch { + return false; + } + }; + + let moved = false; + + // Legacy pointer drag path works more reliably with some CI/browser combos. + const sourceText = sourceRow.getByText(sourceExecution).first(); + const targetText = targetRow.getByText(targetExecution).first(); + const sourceTextBox = await sourceText.boundingBox(); + const targetTextBox = await targetText.boundingBox(); + if (sourceTextBox && targetTextBox) { + await page.mouse.move( + sourceTextBox.x + sourceTextBox.width / 2, + sourceTextBox.y + sourceTextBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + targetTextBox.x + targetTextBox.width / 2, + targetTextBox.y + targetTextBox.height / 2, + { steps: 20 }, + ); + await page.mouse.up(); + moved = await waitForMove(); + } + + try { + if (!moved) { + await sourceHandle.dragTo(targetHandle, { timeout: 3_000 }); + moved = await waitForMove(); + } + } catch { + // Fall back to pointer/keyboard paths when dnd-kit does not trigger drag events in CI. + } + + if (!moved) { + const sourceBox = + (await sourceHandle.boundingBox()) ?? (await sourceRow.boundingBox()); + const targetBox = + (await targetHandle.boundingBox()) ?? (await targetRow.boundingBox()); + + if (sourceBox && targetBox) { + await page.mouse.move( + sourceBox.x + sourceBox.width / 2, + sourceBox.y + sourceBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + Math.min(8, Math.max(2, targetBox.height / 4)), + { steps: 20 }, + ); + await page.mouse.up(); + moved = await waitForMove(); + } + } + + if (!moved) { + try { + await sourceRow.dragTo(targetRow, { timeout: 3_000 }); + moved = await waitForMove(); + } catch { + // Keep trying keyboard fallback below. + } + } + + if (!moved) { + try { + await sourceHandle.focus({ timeout: 2_000 }); + await page.keyboard.press("Space"); + const rows = await treeGrid.getByRole("row").allInnerTexts(); + const sourceIndex = rows.findIndex((row) => + row.includes(sourceExecution), + ); + const targetIndex = rows.findIndex((row) => + row.includes(targetExecution), + ); + const steps = + sourceIndex !== -1 && targetIndex !== -1 && sourceIndex > targetIndex + ? sourceIndex - targetIndex + : 1; + for (let step = 0; step < steps; step++) { + await page.keyboard.press("ArrowUp"); + } + await page.keyboard.press("Space"); + moved = await waitForMove(); + } catch { + // Keep "moved" false and let the caller assert/fail with context. + } + } + + return moved; +} diff --git a/js/apps/admin-ui/test/autentication/flows.spec.ts b/js/apps/admin-ui/test/autentication/flows.spec.ts index fc3d2c2e5640..0f299bc3b45c 100644 --- a/js/apps/admin-ui/test/autentication/flows.spec.ts +++ b/js/apps/admin-ui/test/autentication/flows.spec.ts @@ -29,6 +29,7 @@ import { clickDefaultSwitchPolicy, clickDeleteRow, clickSwitchPolicy, + dragExecutionAboveExecution, fillBindFlowModal, fillCreateForm, fillDuplicateFlowModal, @@ -187,29 +188,18 @@ test.describe("Authentication flow details", () => { }); test("drags and drops execution", async ({ page }) => { + test.setTimeout(60_000); await using testBed = await createTestBed(); await adminClient.copyFlow("browser", flowName, testBed.realm); await login(page, { to: toAuthentication({ realm: testBed.realm }) }); await clickTableRowItem(page, flowName); - - const sourceBox = await page - .getByText("Identity Provider Redirector") - .boundingBox(); - const targetBox = await page.getByText("Kerberos").boundingBox(); - - await page.mouse.move( - sourceBox!.x + sourceBox!.width / 2, - sourceBox!.y + sourceBox!.height / 2, - ); - await page.mouse.down(); - await page.mouse.move( - targetBox!.x + targetBox!.width / 2, - targetBox!.y + targetBox!.height / 2, - { steps: 10 }, + await dragExecutionAboveExecution( + page, + "Identity Provider Redirector", + "Kerberos", ); - await page.mouse.up(); await assertNotificationMessage(page, "Flow successfully updated"); }); diff --git a/js/apps/admin-ui/test/client-scope/oid4vci-client-scope.spec.ts b/js/apps/admin-ui/test/client-scope/oid4vci-client-scope.spec.ts index 5a8fc540d0e4..28a36926f580 100644 --- a/js/apps/admin-ui/test/client-scope/oid4vci-client-scope.spec.ts +++ b/js/apps/admin-ui/test/client-scope/oid4vci-client-scope.spec.ts @@ -13,6 +13,30 @@ import { clickTableRowItem, clickTableToolbarItem } from "../utils/table.ts"; import { login } from "../utils/login.ts"; import { toClientScopes } from "../../src/client-scopes/routes/ClientScopes.tsx"; +type Oid4vciFormat = "SD-JWT VC (dc+sd-jwt)" | "JWT VC (jwt_vc_json)"; +const OID4VCI_PROTOCOL = "OpenID for Verifiable Credentials"; +const OID4VCI_SERVER_FEATURE = "OID4VC_VCI"; +const OID4VCI_OPTION_VISIBLE_TIMEOUT_MS = 5_000; +const OID4VCI_UNAVAILABLE_MESSAGE = + "OID4VCI protocol is unavailable. Start Keycloak with verifiable credentials support enabled."; + +async function skipIfOID4VCIFeatureDisabled() { + const isOID4VCIFeatureEnabled = await adminClient.isFeatureEnabled( + OID4VCI_SERVER_FEATURE, + ); + // eslint-disable-next-line playwright/no-skipped-test -- Explicit environment gate for unsupported server features. + test.skip(!isOID4VCIFeatureEnabled, OID4VCI_UNAVAILABLE_MESSAGE); +} + +async function getVisibleOID4VCIProtocolOption(page: Page) { + const oid4vcOption = page.getByRole("option", { name: OID4VCI_PROTOCOL }); + await oid4vcOption.waitFor({ + state: "visible", + timeout: OID4VCI_OPTION_VISIBLE_TIMEOUT_MS, + }); + return oid4vcOption; +} + // Helper function to create client scope (without selecting protocol) async function createClientScope( page: Page, @@ -21,27 +45,27 @@ async function createClientScope( await login(page, { to: toClientScopes({ realm: testBed.realm }) }); await goToClientScopes(page); - await page.waitForLoadState("domcontentloaded"); + await expect(page.getByPlaceholder("Search for client scope")).toBeVisible(); await clickTableToolbarItem(page, "Create client scope"); - await page.waitForLoadState("domcontentloaded"); + await expect( + page.getByRole("heading", { name: "Create client scope" }), + ).toBeVisible(); + await expect(page.getByTestId("name")).toBeVisible(); } // Helper function to create client scope and select protocol/format async function createClientScopeAndSelectProtocolAndFormat( page: Page, testBed: Awaited>, - format?: "SD-JWT VC (dc+sd-jwt)" | "JWT VC (jwt_vc_json)", + format?: Oid4vciFormat, ) { await createClientScope(page, testBed); - await selectItem(page, "#kc-protocol", "OpenID for Verifiable Credentials"); - - await page.waitForLoadState("domcontentloaded"); + await selectOID4VCIProtocol(page); if (format) { - await selectItem(page, "#kc-vc-format", format); - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, format); } } @@ -56,12 +80,13 @@ async function navigateBackAndVerifyClientScope( await page.goto( `${baseUrl}#${toClientScopes({ realm: testBed.realm }).pathname!}`, ); - await page.waitForLoadState("domcontentloaded"); + await expect(page.getByPlaceholder("Search for client scope")).toBeVisible(); await page.getByPlaceholder("Search for client scope").fill(clientScopeName); + await page.keyboard.press("Enter"); await clickTableRowItem(page, clientScopeName); - await page.waitForLoadState("domcontentloaded"); + await expect(page.getByTestId("name")).toHaveValue(clientScopeName); } // OID4VCI field selectors @@ -108,6 +133,22 @@ const TEST_VALUES = { const TOKEN_JWS_TYPE_WARNING_PREFIX = "The configured Token JWS Type does not match the recommended value for the selected credential format."; +async function selectOID4VCIProtocol(page: Page) { + await skipIfOID4VCIFeatureDisabled(); + + await expect(page.locator("#kc-protocol")).toBeVisible(); + await page.locator("#kc-protocol").click(); + + const oid4vcOption = await getVisibleOID4VCIProtocolOption(page); + await oid4vcOption.click(); + await expect(page.locator("#kc-protocol")).toContainText(OID4VCI_PROTOCOL); +} + +async function selectVCFormat(page: Page, format: Oid4vciFormat) { + await selectItem(page, "#kc-vc-format", format); + await expect(page.locator("#kc-vc-format")).toContainText(format); +} + // Helper function to fill TimeSelector fields (enters value in seconds - the base unit) async function fillTimeSelectorValue( page: Page, @@ -149,22 +190,7 @@ test.describe("OID4VCI Client Scope Functionality", () => { }); await createClientScope(page, testBed); - await expect(page.locator("#kc-protocol")).toBeVisible(); - - const protocolButton = page.locator("#kc-protocol"); - await protocolButton.click(); - - const oid4vcOption = page.getByRole("option", { - name: "OpenID for Verifiable Credentials", - }); - await expect(oid4vcOption).toBeVisible(); - await oid4vcOption.click(); - - await page.waitForLoadState("domcontentloaded"); - - await expect(page.locator("#kc-protocol")).toContainText( - "OpenID for Verifiable Credentials", - ); + await selectOID4VCIProtocol(page); await expect( page.getByTestId(OID4VCI_FIELDS.CREDENTIAL_CONFIGURATION_ID), @@ -294,11 +320,10 @@ test.describe("OID4VCI Client Scope Functionality", () => { await expect(page.locator("#kc-protocol")).toBeVisible(); + await skipIfOID4VCIFeatureDisabled(); await page.locator("#kc-protocol").click(); - - await expect( - page.getByRole("option", { name: "OpenID for Verifiable Credentials" }), - ).toBeVisible(); + const oid4vcOption = await getVisibleOID4VCIProtocolOption(page); + await expect(oid4vcOption).toBeVisible(); }); test("should not display OID4VCI fields when protocol is not OID4VCI", async ({ @@ -318,8 +343,6 @@ test.describe("OID4VCI Client Scope Functionality", () => { await expect(openidConnectOption).toBeVisible(); await openidConnectOption.click(); - await page.waitForLoadState("domcontentloaded"); - await expect( page.getByTestId(OID4VCI_FIELDS.CREDENTIAL_CONFIGURATION_ID), ).toBeHidden(); @@ -350,22 +373,21 @@ test.describe("OID4VCI Client Scope Functionality", () => { await protocolButton.click(); const oid4vcOption = page.getByRole("option", { - name: "OpenID for Verifiable Credentials", + name: OID4VCI_PROTOCOL, }); const openidConnectOption = page.getByRole("option", { name: "OpenID Connect", }); + await skipIfOID4VCIFeatureDisabled(); + const oid4vcVisibleOption = await getVisibleOID4VCIProtocolOption(page); + await expect(oid4vcOption).toBeVisible(); await expect(openidConnectOption).toBeVisible(); - await oid4vcOption.click(); + await oid4vcVisibleOption.click(); - await page.waitForLoadState("domcontentloaded"); - - await expect(page.locator("#kc-protocol")).toContainText( - "OpenID for Verifiable Credentials", - ); + await expect(page.locator("#kc-protocol")).toContainText(OID4VCI_PROTOCOL); await expect( page.getByTestId(OID4VCI_FIELDS.CREDENTIAL_CONFIGURATION_ID), @@ -552,35 +574,27 @@ test.describe("OID4VCI Client Scope Functionality", () => { page.getByTestId(OID4VCI_FIELDS.VERIFIABLE_CREDENTIAL_TYPE), ).toBeVisible(); - await selectItem(page, "#kc-vc-format", "JWT VC (jwt_vc_json)"); - - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, "JWT VC (jwt_vc_json)"); await expect( page.getByTestId(OID4VCI_FIELDS.VERIFIABLE_CREDENTIAL_TYPE), ).toBeHidden(); - await selectItem(page, "#kc-vc-format", "SD-JWT VC (dc+sd-jwt)"); - - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, "SD-JWT VC (dc+sd-jwt)"); await expect( page.getByTestId(OID4VCI_FIELDS.VERIFIABLE_CREDENTIAL_TYPE), ).toBeVisible(); await expect(page.getByTestId(OID4VCI_FIELDS.VISIBLE_CLAIMS)).toBeVisible(); - await selectItem(page, "#kc-vc-format", "JWT VC (jwt_vc_json)"); - - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, "JWT VC (jwt_vc_json)"); await expect( page.getByTestId(OID4VCI_FIELDS.VERIFIABLE_CREDENTIAL_TYPE), ).toBeHidden(); await expect(page.getByTestId(OID4VCI_FIELDS.VISIBLE_CLAIMS)).toBeHidden(); - await selectItem(page, "#kc-vc-format", "SD-JWT VC (dc+sd-jwt)"); - - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, "SD-JWT VC (dc+sd-jwt)"); await expect( page.getByTestId(OID4VCI_FIELDS.VERIFIABLE_CREDENTIAL_TYPE), @@ -600,8 +614,7 @@ test.describe("OID4VCI Client Scope Functionality", () => { await expect(page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE)).toBeVisible(); - await selectItem(page, "#kc-vc-format", "SD-JWT VC (dc+sd-jwt)"); - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, "SD-JWT VC (dc+sd-jwt)"); await expect(page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE)).toBeVisible(); }); @@ -627,8 +640,7 @@ test.describe("OID4VCI Client Scope Functionality", () => { await tokenJwsType.fill("vc+jwt"); await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); - await selectItem(page, "#kc-vc-format", "SD-JWT VC (dc+sd-jwt)"); - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, "SD-JWT VC (dc+sd-jwt)"); await tokenJwsType.fill("vc+jwt"); await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toBeVisible(); @@ -654,8 +666,7 @@ test.describe("OID4VCI Client Scope Functionality", () => { await tokenJwsType.fill(""); await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); - await selectItem(page, "#kc-vc-format", "JWT VC (jwt_vc_json)"); - await page.waitForLoadState("domcontentloaded"); + await selectVCFormat(page, "JWT VC (jwt_vc_json)"); await tokenJwsType.fill(""); await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); @@ -1012,7 +1023,7 @@ test.describe("OID4VCI Client Scope Functionality", () => { await expect( page.getByRole("option", { - name: "OpenID for Verifiable Credentials", + name: OID4VCI_PROTOCOL, }), ).toHaveCount(0); } finally { diff --git a/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts b/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts index 330276a5ab2f..03dcc35d1228 100644 --- a/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts +++ b/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts @@ -1,12 +1,17 @@ -import { expect, test } from "@playwright/test"; +import { test } from "@playwright/test"; import { v4 as uuid } from "uuid"; import { createTestBed } from "../support/testbed.ts"; import { login } from "../utils/login.ts"; import { goToClientScopes, goToClients, goToRealm } from "../utils/sidebar.ts"; -import { clickTableToolbarItem, getRowByCellText } from "../utils/table.ts"; -import { clickSaveButton, selectItem } from "../utils/form.ts"; +import { assertNotificationMessage } from "../utils/masthead.ts"; import { toClients } from "../../src/clients/routes/Clients.tsx"; import { createClient, continueNext, save as saveClient } from "./utils.ts"; +import { + assignOptionalOid4vciClientScope, + createOid4vciClientScope, + openClientScopeSetupTab, + skipIfOID4VCIFeatureDisabled, +} from "./assign-oid4vci-client-scope.ts"; test("OIDC client can assign OID4VCI client scopes", async ({ page }) => { await using testBed = await createTestBed({ @@ -15,50 +20,20 @@ test("OIDC client can assign OID4VCI client scopes", async ({ page }) => { await login(page, { to: toClients({ realm: testBed.realm }) }); await goToRealm(page, testBed.realm); + await skipIfOID4VCIFeatureDisabled(); const clientScopeName = `oid4vci-scope-${uuid()}`; await goToClientScopes(page); - await clickTableToolbarItem(page, "Create client scope"); - await selectItem(page, "#kc-protocol", "OpenID for Verifiable Credentials"); - await page.getByTestId("name").fill(clientScopeName); - await clickSaveButton(page); - await expect(page.getByText("Client scope created")).toBeVisible(); + await createOid4vciClientScope(page, clientScopeName); const clientId = `oidc-client-${uuid()}`; await goToClients(page); await createClient(page, { clientId, protocol: "OpenID Connect" }); await continueNext(page); await saveClient(page); - await expect(page.getByText("Client created successfully")).toBeVisible(); + await assertNotificationMessage(page, "Client created successfully"); await goToClients(page); - await expect(getRowByCellText(page, clientId)).toBeVisible(); - await getRowByCellText(page, clientId).click(); - - await page.getByTestId("clientScopesTab").click(); - await page.getByTestId("clientScopesSetupTab").click(); - await page.getByRole("button", { name: "Add client scope" }).click(); - - await page.getByTestId("filter-type-dropdown").click(); - await page.getByTestId("filter-type-dropdown-item").click(); - await page.locator(".kc-protocolType-select").click(); - await page - .getByRole("option", { name: "OpenID for Verifiable Credentials" }) - .click(); - - await expect( - page.getByRole("gridcell", { name: clientScopeName }), - ).toBeVisible(); - - const scopeRow = page.getByRole("row", { name: clientScopeName }); - await scopeRow.getByRole("checkbox").click(); - - await page.getByTestId("add-dropdown").click(); - await page.getByRole("menuitem", { name: "Optional" }).click(); - - await expect(page.getByText("Scope mapping updated")).toBeVisible(); - - await expect( - page.getByRole("row", { name: new RegExp(clientScopeName, "i") }), - ).toBeVisible(); + await openClientScopeSetupTab(page, clientId); + await assignOptionalOid4vciClientScope(page, clientScopeName); }); diff --git a/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.ts b/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.ts new file mode 100644 index 000000000000..6a7c95ca8996 --- /dev/null +++ b/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.ts @@ -0,0 +1,77 @@ +import { expect, test, type Page } from "@playwright/test"; +import { clickSaveButton, selectItem } from "../utils/form.ts"; +import { clickTableRowItem, getRowByCellText } from "../utils/table.ts"; +import adminClient from "../utils/AdminClient.ts"; + +const OID4VCI_SERVER_FEATURE = "OID4VC_VCI"; +const OID4VCI_PROTOCOL = "OpenID for Verifiable Credentials"; +const OID4VCI_UNAVAILABLE_MESSAGE = + "OID4VCI protocol is unavailable. Start Keycloak with verifiable credentials support enabled."; + +export async function skipIfOID4VCIFeatureDisabled() { + const isOID4VCIFeatureEnabled = await adminClient.isFeatureEnabled( + OID4VCI_SERVER_FEATURE, + ); + // eslint-disable-next-line playwright/no-skipped-test -- Explicit environment gate for unsupported server features. + test.skip(!isOID4VCIFeatureEnabled, OID4VCI_UNAVAILABLE_MESSAGE); +} + +export async function createOid4vciClientScope( + page: Page, + clientScopeName: string, +) { + await skipIfOID4VCIFeatureDisabled(); + await clickCreateClientScopeAction(page); + await selectItem(page, "#kc-protocol", OID4VCI_PROTOCOL); + await page.getByTestId("name").fill(clientScopeName); + await clickSaveButton(page); + await expect(page.getByText("Client scope created")).toBeVisible(); +} + +export async function openClientScopeSetupTab(page: Page, clientId: string) { + await expect(getRowByCellText(page, clientId)).toBeVisible(); + await clickTableRowItem(page, clientId); + await page.getByTestId("clientScopesTab").click(); + await page.getByTestId("clientScopesSetupTab").click(); +} + +export async function assignOptionalOid4vciClientScope( + page: Page, + clientScopeName: string, +) { + await page.getByRole("button", { name: "Add client scope" }).click(); + await page.getByTestId("filter-type-dropdown").click(); + await page.getByTestId("filter-type-dropdown-item").click(); + await page.locator(".kc-protocolType-select").click(); + await page.getByRole("option", { name: OID4VCI_PROTOCOL }).click(); + + await expect( + page.getByRole("gridcell", { name: clientScopeName }), + ).toBeVisible(); + + const scopeRow = page.getByRole("row", { name: clientScopeName }); + await scopeRow.getByRole("checkbox").click(); + + await page.getByTestId("add-dropdown").click(); + await page.getByRole("menuitem", { name: "Optional" }).click(); + + await expect(page.getByText("Scope mapping updated")).toBeVisible(); + await expect( + page.getByRole("row", { name: new RegExp(clientScopeName, "i") }), + ).toBeVisible(); +} + +async function clickCreateClientScopeAction(page: Page) { + const createScopeButton = page.getByRole("button", { + name: /Create client scope/i, + }); + if ((await createScopeButton.count()) > 0) { + await createScopeButton.first().click(); + return; + } + + await page + .getByRole("link", { name: /Create client scope/i }) + .first() + .click(); +} diff --git a/js/apps/admin-ui/test/clients/saml.spec.ts b/js/apps/admin-ui/test/clients/saml.spec.ts index b4143fc10a44..580e71c10051 100644 --- a/js/apps/admin-ui/test/clients/saml.spec.ts +++ b/js/apps/admin-ui/test/clients/saml.spec.ts @@ -6,7 +6,7 @@ import { login } from "../utils/login.ts"; import { assertNotificationMessage } from "../utils/masthead.ts"; import { assertModalTitle, cancelModal, confirmModal } from "../utils/modal.ts"; import { goToClients } from "../utils/sidebar.ts"; -import { clickTableRowItem } from "../utils/table.ts"; +import { clickTableRowItem, searchItem } from "../utils/table.ts"; import { goToAdvancedTab, revertFineGrain, saveFineGrain } from "./advanced.ts"; import { assertCertificates, @@ -85,22 +85,21 @@ test.describe.serial("Fine Grain SAML Endpoint Configuration", () => { }); test.describe.serial("Clients SAML tests", () => { - const clientId = "saml"; - - const clientName = `saml-settings-${uuid()}`; + const clientId = `saml-settings-${uuid()}`; test.beforeAll(() => adminClient.createClient({ protocol: "saml", - clientId: clientName, + clientId, }), ); - test.afterAll(() => adminClient.deleteClient(clientName)); + test.afterAll(() => adminClient.deleteClient(clientId)); test.beforeEach(async ({ page }) => { await login(page); await goToClients(page); + await searchItem(page, "Search for client", clientId); await clickTableRowItem(page, clientId); }); @@ -111,7 +110,12 @@ test.describe.serial("Clients SAML tests", () => { }); test("should save force name id format", async ({ page }) => { - await clickPostBinding(page); + const postBindingResult = await clickPostBinding(page); + // eslint-disable-next-line playwright/no-skipped-test -- Generated SAML clients can expose this setting as read-only. + test.skip( + postBindingResult === "read-only", + "Post binding switch is read-only for this generated SAML client.", + ); await saveSamlSettings(page); await assertNotificationMessage(page, "Client successfully updated"); }); diff --git a/js/apps/admin-ui/test/clients/saml.ts b/js/apps/admin-ui/test/clients/saml.ts index 42f83f5b1b59..4d4b75761067 100644 --- a/js/apps/admin-ui/test/clients/saml.ts +++ b/js/apps/admin-ui/test/clients/saml.ts @@ -1,6 +1,7 @@ import { type Locator, type Page, expect } from "@playwright/test"; import { assertSelectValue, + clickSwitch, selectItem, switchOff, switchOn, @@ -47,7 +48,21 @@ export async function assertSamlClientDetails(page: Page) { } export async function clickPostBinding(page: Page) { - await switchOff(page, "#attributes\\.saml🍺force🍺post🍺binding"); + const postBindingSwitch = page.locator( + "#attributes\\.saml🍺force🍺post🍺binding", + ); + await expect(postBindingSwitch).toBeVisible(); + + const isReadOnly = + (await postBindingSwitch.getAttribute("aria-readonly")) === "true" || + (await postBindingSwitch.getAttribute("readonly")) !== null; + + if (isReadOnly || !(await postBindingSwitch.isEnabled())) { + return "read-only"; + } + + await switchOff(page, postBindingSwitch); + return "toggled"; } export async function saveSamlSettings(page: Page) { @@ -63,7 +78,7 @@ export async function goToClientSettingsTab(page: Page) { } export async function clickClientSignature(page: Page) { - await switchOff(page, "#clientSignature"); + await clickSwitch(page, "#clientSignature"); } // Assert that the number of certificates enabled matches the number of certificates displayed @@ -81,7 +96,8 @@ export async function clickEncryptionAssertions(page: Page) { } export async function clickOffEncryptionAssertions(page: Page) { - await switchOff(page, "#encryptAssertions"); + // Toggling this switch can require confirmation in a modal before state flips. + await clickSwitch(page, "#encryptAssertions"); } export async function clickGenerate(page: Page) { diff --git a/js/apps/admin-ui/test/clients/scope.ts b/js/apps/admin-ui/test/clients/scope.ts index ad58c068d5fa..a2146cae1e43 100644 --- a/js/apps/admin-ui/test/clients/scope.ts +++ b/js/apps/admin-ui/test/clients/scope.ts @@ -1,5 +1,4 @@ import { type Page, expect } from "@playwright/test"; -import { clickTableToolbarItem } from "../utils/table.ts"; import { selectItem } from "../utils/form.ts"; export async function goToClientScopesTab(page: Page) { @@ -11,7 +10,45 @@ export async function goToClientScopeEvaluateTab(page: Page) { } export async function clickAddClientScope(page: Page) { - await clickTableToolbarItem(page, "Add client scope"); + const toolbar = page.getByTestId("table-toolbar"); + await expect(toolbar).toBeVisible(); + const directAction = [ + toolbar.getByRole("button", { name: /Add client scope/i }), + toolbar.getByRole("link", { name: /Add client scope/i }), + toolbar.getByRole("button", { name: /Add scope/i }), + toolbar.getByRole("link", { name: /Add scope/i }), + toolbar.getByRole("button", { name: /^Add$/i }), + toolbar.getByRole("link", { name: /^Add$/i }), + ]; + + for (const action of directAction) { + const actionCount = await action.count(); + for (let i = 0; i < actionCount; i++) { + const candidate = action.nth(i); + if (await candidate.isVisible()) { + await candidate.click(); + return; + } + } + } + + if ((await toolbar.getByTestId("kebab").count()) > 0) { + await toolbar.getByTestId("kebab").first().click(); + const menuAction = [ + page.getByRole("menuitem", { name: /Add client scope/i }), + page.getByRole("menuitem", { name: /Add scope/i }), + page.getByRole("menuitem", { name: /^Add$/i }), + ]; + for (const action of menuAction) { + if ((await action.count()) > 0) { + await action.first().click(); + return; + } + } + } + throw new Error( + `Could not find add scope action in toolbar: ${await toolbar.textContent()}`, + ); } export async function clickAddScope(page: Page, option: string) { diff --git a/js/apps/admin-ui/test/groups/list.spec.ts b/js/apps/admin-ui/test/groups/list.spec.ts index 294602ff57f9..ec52ab783279 100644 --- a/js/apps/admin-ui/test/groups/list.spec.ts +++ b/js/apps/admin-ui/test/groups/list.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; import { v4 as uuid } from "uuid"; +import { createTestBed, type TestBed } from "../support/testbed.ts"; import adminClient from "../utils/AdminClient.ts"; import { login } from "../utils/login.ts"; import { @@ -8,7 +9,7 @@ import { selectActionToggleItem, } from "../utils/masthead.ts"; import { cancelModal, confirmModal } from "../utils/modal.ts"; -import { goToGroups } from "../utils/sidebar.ts"; +import { goToGroups, goToRealm } from "../utils/sidebar.ts"; import { assertNoResults, assertRowExists, @@ -23,28 +24,34 @@ import { goToGroupDetails } from "./util.ts"; test.describe.serial("Group test", () => { const groupName = `group-${uuid()}`; + let testBed: TestBed; const users: { id: string; username: string }[] = []; - const username = "test-user"; + const usernamePrefix = `test-user-${uuid()}-`; test.beforeAll(async () => { + testBed = await createTestBed(); for (let i = 0; i < 5; i++) { + const username = `${usernamePrefix}${i}`; const user = await adminClient.createUser({ - username: username + i, + username, enabled: true, + realm: testBed.realm, }); - users.push({ id: user.id!, username: username + i }); + users.push({ id: user.id!, username }); } }); test.afterAll(async () => { - await adminClient.deleteGroups(); - for (let i = 0; i < 5; i++) { - await adminClient.deleteUser(username + i); + await adminClient.deleteGroups(testBed.realm); + for (const { username } of users) { + await adminClient.deleteUser(username, testBed.realm, true); } + await testBed[Symbol.asyncDispose](); }); test.beforeEach(async ({ page }) => { await login(page); + await goToRealm(page, testBed.realm); await goToGroups(page); }); @@ -64,7 +71,7 @@ test.describe.serial("Group test", () => { await searchGroup(page, secondGroupName); await assertRowExists(page, secondGroupName, true); - await adminClient.deleteGroups(); + await adminClient.deleteGroups(testBed.realm); }); test("Fail to create group with empty name", async ({ page }) => { @@ -87,20 +94,35 @@ test.describe.serial("Group test", () => { }); test.describe.serial("Search group under current group", () => { - const predefinedGroups = ["level", "level1", "level2", "level3"]; + const groupSuffix = uuid(); + const predefinedGroups = ["level", "level1", "level2", "level3"].map( + (group) => `${group}-${groupSuffix}`, + ); + let testBed: TestBed; const placeholder = "Filter groups"; const tableName = "Groups"; + test.beforeAll(async () => { + testBed = await createTestBed(); + }); + test.beforeEach(async ({ page }) => { for (const group of predefinedGroups) { - await adminClient.createGroup(group); + await adminClient.createGroup(group, testBed.realm); } await login(page); + await goToRealm(page, testBed.realm); await goToGroups(page); }); - test.afterEach(() => adminClient.deleteGroups()); + test.afterEach(async () => { + await adminClient.deleteGroups(testBed.realm); + }); + + test.afterAll(async () => { + await testBed[Symbol.asyncDispose](); + }); test("Search group that exists", async ({ page }) => { await searchItem(page, placeholder, predefinedGroups[1]); @@ -135,7 +157,7 @@ test.describe.serial("Search group under current group", () => { }); test("Edit group", async ({ page }) => { - const newGroupName = "new_group_name"; + const newGroupName = `new-group-name-${groupSuffix}`; const description = "new description"; await clickRowKebabItem(page, predefinedGroups[3], "Edit"); await editGroup(page, newGroupName, description); diff --git a/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts b/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts index 8a0419b90fd8..31bd6cf6cf66 100644 --- a/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts +++ b/js/apps/admin-ui/test/identity-providers/default-trust.spec.ts @@ -4,11 +4,11 @@ import { login } from "../utils/login.ts"; import { assertNotificationMessage } from "../utils/masthead.ts"; import { goToIdentityProviders } from "../utils/sidebar.ts"; import { clickTableRowItem } from "../utils/table.ts"; +import { SERVER_URL } from "../utils/constants.ts"; import { clickSaveButton, createDefaultTrustProvider } from "./main.ts"; const alias = "default-trust"; -const addDefaultTrustProviderUrl = - "http://localhost:8080/admin/master/console/#/master/identity-providers/default-trust/add"; +const addDefaultTrustProviderUrl = `${SERVER_URL}/admin/master/console/#/master/identity-providers/default-trust/add`; const jwksUrl = "https://localhost/realms/test/protocol/openid-connect/certs"; const jwks = '{"keys":[]}'; diff --git a/js/apps/admin-ui/test/identity-providers/main.ts b/js/apps/admin-ui/test/identity-providers/main.ts index 48d84abfb5be..e73d4fe80207 100644 --- a/js/apps/admin-ui/test/identity-providers/main.ts +++ b/js/apps/admin-ui/test/identity-providers/main.ts @@ -1,7 +1,7 @@ import { type Page, expect } from "@playwright/test"; import { assertNotificationMessage } from "../utils/masthead.ts"; +import { SERVER_URL } from "../utils/constants.ts"; -const SERVER_URL = "http://localhost:8080"; const discoveryUrl = `${SERVER_URL}/realms/master/.well-known/openid-configuration`; const authorizationUrl = `${SERVER_URL}/realms/master/protocol/openid-connect/auth`; diff --git a/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts b/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts index 3f37cc7b33da..79bcc68d781c 100644 --- a/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts +++ b/js/apps/admin-ui/test/identity-providers/saml-signature-defaults.spec.ts @@ -1,8 +1,8 @@ import { expect, test } from "@playwright/test"; import { login } from "../utils/login.ts"; +import { SERVER_URL } from "../utils/constants.ts"; -const addSamlProviderUrl = - "http://localhost:8080/admin/master/console/#/master/identity-providers/saml/add"; +const addSamlProviderUrl = `${SERVER_URL}/admin/master/console/#/master/identity-providers/saml/add`; test("should enable SAML signature switches by default", async ({ page }) => { await login(page); diff --git a/js/apps/admin-ui/test/identity-providers/saml.ts b/js/apps/admin-ui/test/identity-providers/saml.ts index a86891a46455..4a2ec35636b7 100644 --- a/js/apps/admin-ui/test/identity-providers/saml.ts +++ b/js/apps/admin-ui/test/identity-providers/saml.ts @@ -1,5 +1,5 @@ import { type Page, expect } from "@playwright/test"; -import { selectItem, switchOff, switchOn } from "../utils/form.ts"; +import { selectItem } from "../utils/form.ts"; import { assertNotificationMessage } from "../utils/masthead.ts"; import { confirmModal } from "../utils/modal.ts"; import { goToIdentityProviders } from "../utils/sidebar.ts"; @@ -11,15 +11,20 @@ import { } from "./main.ts"; export async function editSAMLSettings(page: Page, samlProviderName: string) { + const providerEnabledSwitch = page.locator("#-switch"); // Toggle provider state - await switchOff(page, "#-switch"); - await confirmModal(page); - await assertNotificationMessage(page, "Provider successfully updated"); + if (await providerEnabledSwitch.isChecked()) { + await providerEnabledSwitch.click({ force: true }); + await confirmModal(page); + await assertNotificationMessage(page, "Provider successfully updated"); + } await goToIdentityProviders(page); await expect(page.getByText("Disabled")).toBeVisible(); await clickTableRowItem(page, samlProviderName); - await switchOn(page, "#-switch"); + if (!(await providerEnabledSwitch.isChecked())) { + await providerEnabledSwitch.click({ force: true }); + } // Verify and configure settings await setUrl(page, "singleSignOnService", "invalid"); @@ -45,18 +50,20 @@ export async function editSAMLSettings(page: Page, samlProviderName: string) { // Toggle SAML switches const switches = [ - "config.allowCreate", - "config.wantAssertionsEncrypted", - "config.forceAuthn", + page.getByTestId("config.allowCreate"), + page.getByTestId("config.wantAssertionsEncrypted"), + page.getByTestId("config.forceAuthn"), ]; - for (const switchId of switches) { - await switchOn(page, `[data-testid="${switchId}"]`); + for (const field of switches) { + await field.check({ force: true }); } - const switchOffIds = ["config.sendIdTokenOnLogout"]; - for (const switchId of switchOffIds) { - await switchOff(page, `[data-testid="${switchId}"]`); - } + await page.getByTestId("config.sendIdTokenOnLogout").uncheck({ force: true }); + + // Ensure there is always a persisted change even when defaults already match. + await page + .getByTestId("displayName") + .fill(`SAML edited ${Date.now().toString().slice(-6)}`); await clickSaveButton(page); } diff --git a/js/apps/admin-ui/test/masthead/realm.spec.ts b/js/apps/admin-ui/test/masthead/realm.spec.ts index d9bf68b27335..0016aac439b1 100644 --- a/js/apps/admin-ui/test/masthead/realm.spec.ts +++ b/js/apps/admin-ui/test/masthead/realm.spec.ts @@ -2,7 +2,7 @@ import { test } from "@playwright/test"; import { v4 as uuid } from "uuid"; import adminClient from "../utils/AdminClient.ts"; import { DEFAULT_REALM } from "../utils/constants.ts"; -import { assertRequiredFieldError, switchOff } from "../utils/form.ts"; +import { assertRequiredFieldError, clickSwitch } from "../utils/form.ts"; import { login } from "../utils/login.ts"; import { assertNotificationMessage, @@ -80,7 +80,7 @@ test.describe.serial("Realm tests", () => { await goToRealmSettings(page); - await switchOff(page, `#${testDisabledName}-switch`); + await clickSwitch(page, `#${testDisabledName}-switch`); await confirmModal(page); await assertNotificationMessage(page, "Realm successfully updated"); diff --git a/js/apps/admin-ui/test/realm-settings/general.spec.ts b/js/apps/admin-ui/test/realm-settings/general.spec.ts index f17df51135c3..106ed0cae53b 100644 --- a/js/apps/admin-ui/test/realm-settings/general.spec.ts +++ b/js/apps/admin-ui/test/realm-settings/general.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { v4 as uuid } from "uuid"; import adminClient from "../utils/AdminClient.ts"; -import { switchOff, switchOn } from "../utils/form.ts"; +import { clickSwitch, switchOff, switchOn } from "../utils/form.ts"; import { login } from "../utils/login.ts"; import { assertNotificationMessage } from "../utils/masthead.ts"; import { confirmModal } from "../utils/modal.ts"; @@ -69,7 +69,7 @@ test.describe.serial("Realm settings general tab tests", () => { await assertNotificationMessage(page, "Realm successfully updated"); // Disable realm - await switchOff(page, realmSwitch); + await clickSwitch(page, realmSwitch); await confirmModal(page); await assertNotificationMessage(page, "Realm successfully updated"); }); diff --git a/js/apps/admin-ui/test/realm-settings/login.spec.ts b/js/apps/admin-ui/test/realm-settings/login.spec.ts index 4d8dc8bbaba5..9efcfee1c3be 100644 --- a/js/apps/admin-ui/test/realm-settings/login.spec.ts +++ b/js/apps/admin-ui/test/realm-settings/login.spec.ts @@ -1,14 +1,14 @@ import { type Page, test } from "@playwright/test"; import { v4 as uuid } from "uuid"; import adminClient from "../utils/AdminClient.ts"; -import { assertSwitchIsChecked, switchToggle } from "../utils/form.ts"; +import { assertSwitchIsChecked } from "../utils/form.ts"; import { login } from "../utils/login.ts"; import { goToClientScopes, goToRealm, goToRealmSettings, } from "../utils/sidebar.ts"; -import { goToLoginTab } from "./login.ts"; +import { goToLoginTab, toggleLoginSettingAndExpectSuccess } from "./login.ts"; test.describe.serial("Realm settings tabs tests", () => { const realmName = `realm-settings_${uuid()}`; @@ -28,7 +28,7 @@ test.describe.serial("Realm settings tabs tests", () => { realmSwitch: string, expectedValue: boolean, ) => { - await switchToggle(page, `[data-testid="${realmSwitch}"]`); + await toggleLoginSettingAndExpectSuccess(page, realmSwitch); await goToClientScopes(page); await goToRealmSettings(page); diff --git a/js/apps/admin-ui/test/realm-settings/login.ts b/js/apps/admin-ui/test/realm-settings/login.ts index 4ae2b02159d9..896729524de6 100644 --- a/js/apps/admin-ui/test/realm-settings/login.ts +++ b/js/apps/admin-ui/test/realm-settings/login.ts @@ -1,5 +1,29 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; +import { switchToggle } from "../utils/form.ts"; export async function goToLoginTab(page: Page) { await page.getByTestId("rs-login-tab").click(); } + +export async function toggleLoginSettingAndExpectSuccess( + page: Page, + switchTestId: string, +) { + const toggle = page.getByTestId(switchTestId); + await expect(toggle).toBeVisible(); + const previousState = await toggle.isChecked(); + + const previousAlert = page.getByTestId("last-alert"); + if (await previousAlert.isVisible()) { + await previousAlert.locator("button").click(); + await expect(previousAlert).toBeHidden(); + } + + await switchToggle(page, toggle); + await expect + .poll(async () => await toggle.isChecked(), { timeout: 10_000 }) + .toBe(!previousState); + await expect(page.getByTestId("last-alert")).toContainText( + /changed successfully/i, + ); +} diff --git a/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts b/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts index e58ae6fd37f1..4b4585722896 100644 --- a/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts +++ b/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts @@ -15,7 +15,9 @@ import { goToLoginTab } from "./login.ts"; import { clickAddValidator, clickCancelAttribute, + clickCreateUser, clickCreateAttribute, + fillEmailAndOptionalUsername, clickSaveAttribute, clickSaveValidator, fillAttributeForm, @@ -60,6 +62,7 @@ test.describe.serial("User profile tabs", () => { test.afterEach(async () => { await adminClient.deleteUser("testuser7", realmName, true); + await adminClient.deleteUser("testuser8@gmail.com", realmName, true); await adminClient.deleteUser("testuser9@gmail.com", realmName, true); await adminClient.deleteUser("testuser10", realmName, true); await adminClient.deleteUser("testuser11", realmName, true); @@ -146,7 +149,7 @@ test.describe.serial("User profile tabs", () => { await switchOn(page, "#kc-edit-username-switch"); await goToUsers(page); - await page.getByTestId("no-users-found-empty-action").click(); + await clickCreateUser(page); await expect(page.getByTestId(attrName)).toBeHidden(); await page.getByTestId("username").fill("testuser7"); await page.getByTestId("user-creation-save").click(); @@ -168,8 +171,12 @@ test.describe.serial("User profile tabs", () => { await switchOn(page, "#kc-email-as-username-switch"); await goToUsers(page); - await page.getByTestId("no-users-found-empty-action").click(); - await page.getByTestId("email").fill("testuser8@gmail.com"); + await clickCreateUser(page); + await fillEmailAndOptionalUsername( + page, + "testuser8@gmail.com", + "testuser8", + ); await expect(page.getByTestId(attrName)).toBeHidden(); await page.getByTestId("user-creation-save").click(); await assertNotificationMessage(page, "The user has been created"); @@ -196,7 +203,7 @@ test.describe.serial("User profile tabs", () => { await switchOffIfOn(page, "#kc-email-as-username-switch"); await goToUsers(page); - await page.getByTestId("no-users-found-empty-action").click(); + await clickCreateUser(page); await expect(page.getByTestId(attrName)).toBeVisible(); await page.getByTestId("username").fill("testuser10"); await page.getByTestId("user-creation-save").click(); @@ -221,7 +228,7 @@ test.describe.serial("User profile tabs", () => { await clickSaveAttribute(page); await goToUsers(page); - await page.getByTestId("no-users-found-empty-action").click(); + await clickCreateUser(page); await expect(page.getByTestId(attrName)).toBeVisible(); await page.getByTestId("username").fill("testuser11"); await page.getByTestId("user-creation-save").click(); @@ -255,7 +262,7 @@ test.describe.serial("User profile tabs", () => { ); await goToUsers(page); - await page.getByTestId("no-users-found-empty-action").click(); + await clickCreateUser(page); await expect(page.getByRole("heading", { name: group })).toBeVisible(); }); diff --git a/js/apps/admin-ui/test/realm-settings/userprofile.ts b/js/apps/admin-ui/test/realm-settings/userprofile.ts index 1f4e0bed679a..1fd29ed4b0bd 100644 --- a/js/apps/admin-ui/test/realm-settings/userprofile.ts +++ b/js/apps/admin-ui/test/realm-settings/userprofile.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; export async function goToUserProfileTab(page: Page) { await page.getByTestId("rs-user-profile-tab").click(); @@ -51,3 +51,30 @@ export async function switchOffIfOn(page: Page, selector: string) { await page.locator(selector).click({ force: true }); } } + +export async function clickCreateUser(page: Page) { + const emptyAction = page.getByTestId("no-users-found-empty-action"); + const addUser = page.getByTestId("add-user"); + await expect(emptyAction.or(addUser).first()).toBeVisible({ + timeout: 15_000, + }); + + if ((await emptyAction.count()) > 0 && (await emptyAction.isVisible())) { + await emptyAction.click(); + return; + } + + await addUser.click(); +} + +export async function fillEmailAndOptionalUsername( + page: Page, + email: string, + username: string, +) { + await page.getByTestId("email").fill(email); + const usernameField = page.getByTestId("username"); + if (await usernameField.isVisible()) { + await usernameField.fill(username); + } +} diff --git a/js/apps/admin-ui/test/user-federation/kerberos.spec.ts b/js/apps/admin-ui/test/user-federation/kerberos.spec.ts index f9daa7095403..0673d29f15ab 100644 --- a/js/apps/admin-ui/test/user-federation/kerberos.spec.ts +++ b/js/apps/admin-ui/test/user-federation/kerberos.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { v4 as uuid } from "uuid"; import adminClient from "../utils/AdminClient.ts"; -import { selectItem, switchOff, switchToggle } from "../utils/form.ts"; +import { clickSwitch, selectItem, switchToggle } from "../utils/form.ts"; import { login } from "../utils/login.ts"; import { assertNotificationMessage } from "../utils/masthead.ts"; import { confirmModal } from "../utils/modal.ts"; @@ -194,7 +194,7 @@ test.describe.serial("User Fed Kerberos tests", () => { test("Should disable an existing Kerberos provider", async ({ page }) => { await clickUserFederationCard(page, firstKerberosName); - await switchOff(page, "#Kerberos-switch"); + await clickSwitch(page, "#Kerberos-switch"); await confirmModal(page); await assertNotificationMessage(page, savedSuccessMessage); diff --git a/js/apps/admin-ui/test/utils/AdminClient.ts b/js/apps/admin-ui/test/utils/AdminClient.ts index 1c14914aecfd..14260fae8691 100644 --- a/js/apps/admin-ui/test/utils/AdminClient.ts +++ b/js/apps/admin-ui/test/utils/AdminClient.ts @@ -11,17 +11,23 @@ import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/def import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata.js"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js"; import type { Credentials } from "@keycloak/keycloak-admin-client/lib/utils/auth.js"; +import { + ADMIN_PASSWORD, + ADMIN_USER, + DEFAULT_REALM, + SERVER_URL, +} from "./constants.ts"; class AdminClient { readonly #client = new KeycloakAdminClient({ - baseUrl: "http://localhost:8080", - realmName: "master", + baseUrl: SERVER_URL, + realmName: DEFAULT_REALM, }); #login() { return this.#client.auth({ - username: "admin", - password: "admin", + username: ADMIN_USER, + password: ADMIN_PASSWORD, grantType: "password", clientId: "admin-cli", }); @@ -73,10 +79,14 @@ class AdminClient { ).at(0); } - async deleteClient(clientName: string) { - const client = await this.getClient(clientName); + async deleteClient( + clientName: string, + realmName: string = this.#client.realmName, + ) { + const client = await this.getClient(clientName, realmName); if (client) { - await this.#client.clients.del({ id: client.id! }); + await this.#login(); + await this.#client.clients.del({ id: client.id!, realm: realmName }); } } @@ -103,11 +113,11 @@ class AdminClient { return createdGroups; } - async deleteGroups() { + async deleteGroups(realm: string = this.#client.realmName) { await this.#login(); - const groups = await this.#client.groups.find(); + const groups = await this.#client.groups.find({ realm }); for (const group of groups) { - await this.#client.groups.del({ id: group.id! }); + await this.#client.groups.del({ id: group.id!, realm }); } } @@ -339,6 +349,24 @@ class AdminClient { }); } + async isFeatureEnabled( + featureName: string, + realm: string = this.#client.realmName, + ): Promise { + await this.#login(); + const features = (await this.#client.serverInfo.find({ realm })).features; + const normalizeFeatureName = (name?: string) => name?.replace(/_V\d+$/, ""); + + return ( + features?.some( + (feature) => + feature.enabled && + normalizeFeatureName(feature.name) === + normalizeFeatureName(featureName), + ) ?? false + ); + } + async deleteIdentityProvider(idpAlias: string) { await this.#login(); await this.#client.identityProviders.del({ diff --git a/js/apps/admin-ui/test/utils/constants.ts b/js/apps/admin-ui/test/utils/constants.ts index 1c059f438f4a..fdd38f1b38d1 100644 --- a/js/apps/admin-ui/test/utils/constants.ts +++ b/js/apps/admin-ui/test/utils/constants.ts @@ -1,5 +1,10 @@ -export const SERVER_URL = "http://localhost:8080"; +const DEFAULT_SERVER_URL = "http://localhost:8080"; +const normalizeServerUrl = (url: string) => url.replace(/\/$/, ""); + +export const SERVER_URL = normalizeServerUrl( + process.env.KEYCLOAK_SERVER_URL ?? DEFAULT_SERVER_URL, +); export const ROOT_PATH = "/admin/:realm/console"; export const DEFAULT_REALM = "master"; -export const ADMIN_USER = "admin"; -export const ADMIN_PASSWORD = "admin"; +export const ADMIN_USER = process.env.KEYCLOAK_ADMIN_USER ?? "admin"; +export const ADMIN_PASSWORD = process.env.KEYCLOAK_ADMIN_PASSWORD ?? "admin"; diff --git a/js/apps/admin-ui/test/utils/form.ts b/js/apps/admin-ui/test/utils/form.ts index 6b1e05c5c522..d88b59490a0a 100644 --- a/js/apps/admin-ui/test/utils/form.ts +++ b/js/apps/admin-ui/test/utils/form.ts @@ -1,6 +1,13 @@ import { expect, Locator, Page } from "@playwright/test"; import { clickSelectRow } from "./table.ts"; +function isPageClosedError(error: unknown): boolean { + return ( + error instanceof Error && + /Target page, context or browser has been closed/i.test(error.message) + ); +} + export async function assertRequiredFieldError(page: Page, field: string) { await expect(page.getByTestId(field + "-helper")).toHaveText(/required/i); } @@ -19,7 +26,16 @@ export async function selectItem( value: string, ) { const element = typeof field === "string" ? page.locator(field) : field; - await element.click(); + await expect(element).toBeVisible(); + await expect(element).toBeEnabled(); + try { + await element.click({ timeout: 3_000 }); + } catch (error) { + if (isPageClosedError(error)) { + throw error; + } + await element.click({ force: true, timeout: 3_000 }); + } await page.getByRole("option", { name: value, exact: true }).click(); } @@ -30,20 +46,22 @@ export async function assertSelectValue(field: Locator, value: string) { export async function switchOn(page: Page, id: string | Locator) { const switchElement = typeof id === "string" ? page.locator(id) : id; - if (await switchElement.isChecked()) return; - await switchElement.click({ force: true }); - await expect(switchElement).toBeChecked(); + await setSwitchState(switchElement, true); } export async function switchOff(page: Page, id: string | Locator) { const switchElement = typeof id === "string" ? page.locator(id) : id; - await expect(switchElement).toBeChecked(); - await switchElement.click({ force: true }); + await setSwitchState(switchElement, false); } export async function switchToggle(page: Page, id: string | Locator) { const switchElement = typeof id === "string" ? page.locator(id) : id; - await switchElement.click({ force: true }); + await setSwitchState(switchElement, !(await switchElement.isChecked())); +} + +export async function clickSwitch(page: Page, id: string | Locator) { + const switchElement = typeof id === "string" ? page.locator(id) : id; + await clickSwitchElement(switchElement); } export async function assertSwitchIsChecked( @@ -78,6 +96,99 @@ async function clickOption(page: Page, option: string) { await page.getByRole("option", { name: option }).click(); } +async function clickSwitchElement(switchElement: Locator) { + await expect(switchElement).toBeVisible(); + + const switchId = await switchElement.getAttribute("id"); + const label = switchId + ? switchElement.page().locator(`label[for="${switchId}"]`).first() + : undefined; + + try { + await switchElement.click({ timeout: 3_000 }); + return; + } catch (error) { + if (isPageClosedError(error)) { + throw error; + } + } + + if (label && (await label.count()) > 0) { + try { + await label.click({ force: true, timeout: 3_000 }); + return; + } catch (error) { + if (isPageClosedError(error)) { + throw error; + } + } + } + + await switchElement.click({ force: true, timeout: 3_000 }); +} + +async function setSwitchState(switchElement: Locator, checked: boolean) { + for (let attempt = 0; attempt < 3; attempt++) { + await expect(switchElement).toBeVisible(); + + if ((await switchElement.isChecked()) === checked) { + return; + } + + try { + if (checked) { + await switchElement.check({ force: true, timeout: 3_000 }); + } else { + await switchElement.uncheck({ force: true, timeout: 3_000 }); + } + } catch (error) { + if (isPageClosedError(error)) { + throw error; + } + } + + if (await waitForSwitchState(switchElement, checked)) { + return; + } + + await clickSwitchElement(switchElement); + if (await waitForSwitchState(switchElement, checked)) { + return; + } + + // Some switches only respond to keyboard interactions after focus. + try { + await switchElement.focus({ timeout: 2_000 }); + await switchElement.page().keyboard.press("Space"); + } catch (error) { + if (isPageClosedError(error)) { + throw error; + } + } + + if (await waitForSwitchState(switchElement, checked)) { + return; + } + } + + if (checked) { + await expect(switchElement).toBeChecked(); + } else { + await expect(switchElement).not.toBeChecked(); + } +} + +async function waitForSwitchState(switchElement: Locator, checked: boolean) { + try { + await expect + .poll(async () => await switchElement.isChecked(), { timeout: 2_000 }) + .toBe(checked); + return true; + } catch { + return false; + } +} + export async function selectClient(page: Page, clientName: string) { await page.getByTestId("select-client-button").click(); const modal = page.getByTestId("select-client-modal"); diff --git a/js/apps/admin-ui/test/utils/masthead.ts b/js/apps/admin-ui/test/utils/masthead.ts index a77a89c80a9f..90573c4e626b 100644 --- a/js/apps/admin-ui/test/utils/masthead.ts +++ b/js/apps/admin-ui/test/utils/masthead.ts @@ -1,8 +1,12 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, Page } from "@playwright/test"; +const ALERT_TIMEOUT = 15_000; + export async function assertNotificationMessage(page: Page, message: string) { - await expect(page.getByTestId("last-alert")).toHaveText(message); + const alert = page.getByTestId("last-alert"); + await expect(alert).toBeVisible({ timeout: ALERT_TIMEOUT }); + await expect(alert).toContainText(message, { timeout: ALERT_TIMEOUT }); } function getActionToggleButton(page: Page) { diff --git a/js/apps/admin-ui/test/utils/table.ts b/js/apps/admin-ui/test/utils/table.ts index efb6c05cbc2f..3cf3f57756ce 100644 --- a/js/apps/admin-ui/test/utils/table.ts +++ b/js/apps/admin-ui/test/utils/table.ts @@ -14,12 +14,59 @@ export async function clearAllFilters(page: Page) { await page.getByTestId("clear-all-filters-empty-action").click(); } +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export async function clickTableRowItem(page: Page, itemName: string) { - await page.getByRole("link", { name: itemName }).first().click(); + const tableBody = page.locator("table tbody"); + await tableBody.waitFor(); + + const exactNameRegex = new RegExp(`^${escapeRegex(itemName)}$`, "i"); + const exactTableLink = tableBody + .getByRole("link", { name: itemName, exact: true }) + .first(); + + if ((await exactTableLink.count()) > 0) { + await exactTableLink.click(); + return; + } + + const normalizedExactTableLink = tableBody + .getByRole("link", { name: exactNameRegex }) + .first(); + if ((await normalizedExactTableLink.count()) > 0) { + await normalizedExactTableLink.click(); + return; + } + + const rowWithMatchingLink = tableBody + .locator("tr") + .filter({ has: page.getByRole("link", { name: exactNameRegex }) }) + .first(); + if ((await rowWithMatchingLink.count()) > 0) { + await rowWithMatchingLink + .getByRole("link", { name: exactNameRegex }) + .first() + .click(); + return; + } + + const looseTableLink = tableBody + .getByRole("link", { name: itemName }) + .first(); + if ((await looseTableLink.count()) > 0) { + await looseTableLink.click(); + return; + } + + throw new Error(`Table row item "${itemName}" not found`); } export function getRowByCellText(page: Page, cellText: string): Locator { - return page.getByText(cellText, { exact: true }); + return page + .locator("table tbody tr") + .filter({ has: page.getByText(cellText, { exact: true }) }); } export async function clickRowKebabItem( @@ -39,10 +86,11 @@ export async function assertRowExists( itemName: string, exist = true, ) { + const row = page.locator("table tbody").getByRole("row", { name: itemName }); if (exist) { - await expect(page.getByRole("row", { name: itemName })).toBeVisible(); + await expect(row.first()).toBeVisible(); } else { - await expect(page.getByRole("row", { name: itemName })).toBeHidden(); + await expect(row).toHaveCount(0); } } @@ -57,24 +105,81 @@ export async function clickTableToolbarItem( itemName: string, kebab = false, ) { + const toolbar = page.getByTestId("table-toolbar"); if (kebab) { - await page.getByTestId("kebab").click(); + await toolbar.getByTestId("kebab").click(); + const exactMenuItem = page.getByRole("menuitem", { + name: itemName, + exact: true, + }); + if ((await exactMenuItem.count()) > 0) { + await exactMenuItem.first().click(); + return; + } + await page.getByRole("menuitem", { name: itemName }).first().click(); + return; } - return page - .locator(`[data-testid="table-toolbar"]`) - .getByText(itemName) - .click(); + + const exactToolbarItem = toolbar + .getByRole("button", { name: itemName, exact: true }) + .or(toolbar.getByRole("link", { name: itemName, exact: true })) + .first(); + try { + await exactToolbarItem.waitFor({ state: "visible", timeout: 2_000 }); + await exactToolbarItem.click(); + return; + } catch { + // Fall through to partial name and overflow menu attempts. + } + + const partialToolbarItem = toolbar + .getByRole("button", { name: itemName }) + .or(toolbar.getByRole("link", { name: itemName })) + .first(); + try { + await partialToolbarItem.waitFor({ state: "visible", timeout: 2_000 }); + await partialToolbarItem.click(); + return; + } catch { + // Fall through to overflow menu attempt. + } + + const overflowKebab = toolbar.getByTestId("kebab"); + if ((await overflowKebab.count()) > 0) { + await overflowKebab.click(); + const exactMenuItem = page.getByRole("menuitem", { + name: itemName, + exact: true, + }); + if ((await exactMenuItem.count()) > 0) { + await exactMenuItem.first().click(); + return; + } + await page.getByRole("menuitem", { name: itemName }).first().click(); + return; + } + + throw new Error(`Toolbar item "${itemName}" not found`); } export async function getTableData(page: Page, name: string) { const rowsLocator = await getTableRows(page, name); - const rows = await rowsLocator.elementHandles(); - const tableData = await Promise.all( - rows.map(async (row) => { - const cells = await row.$$("td"); - return await Promise.all(cells.map((cell) => cell.innerText())); - }), - ); + const rowCount = await rowsLocator.count(); + const tableData: string[][] = []; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const row = rowsLocator.nth(rowIndex); + const cells = row.locator("td"); + const cellCount = await cells.count(); + const rowData: string[] = []; + + for (let cellIndex = 0; cellIndex < cellCount; cellIndex++) { + rowData.push((await cells.nth(cellIndex).innerText()).trim()); + } + + tableData.push(rowData); + } + return tableData; } @@ -112,8 +217,18 @@ export async function clickSelectRow( row: number | string, ) { if (typeof row === "string") { - const rows = await getTableData(page, tableName); - const rowIndex = rows.findIndex((r) => r.includes(row as string)); + let rows: string[][] = []; + let rowIndex = -1; + + for (let attempt = 0; attempt < 6; attempt++) { + rows = await getTableData(page, tableName); + rowIndex = rows.findIndex((r) => r.includes(row as string)); + if (rowIndex !== -1) { + break; + } + await page.waitForTimeout(250); + } + if (rowIndex === -1) { throw new Error(`Row ${row} not found: ${rows}`); } From 346604a3a99a78e8bcfde72d6f89d08d3025ced3 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Tue, 28 Jul 2026 10:19:32 +0200 Subject: [PATCH 2/2] added attempts to table clicks Signed-off-by: Erik Jan de Wit --- js/apps/admin-ui/test/utils/table.ts | 78 +++++++++++++++++----------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/js/apps/admin-ui/test/utils/table.ts b/js/apps/admin-ui/test/utils/table.ts index 3cf3f57756ce..8cffe0ba8c51 100644 --- a/js/apps/admin-ui/test/utils/table.ts +++ b/js/apps/admin-ui/test/utils/table.ts @@ -18,46 +18,62 @@ function escapeRegex(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +async function clickLinkWhenAvailable(link: Locator): Promise { + if ((await link.count()) === 0 || !(await link.first().isVisible())) { + return false; + } + + try { + await link.first().click({ timeout: 1_000 }); + return true; + } catch { + return false; + } +} + export async function clickTableRowItem(page: Page, itemName: string) { const tableBody = page.locator("table tbody"); await tableBody.waitFor(); const exactNameRegex = new RegExp(`^${escapeRegex(itemName)}$`, "i"); - const exactTableLink = tableBody - .getByRole("link", { name: itemName, exact: true }) - .first(); - if ((await exactTableLink.count()) > 0) { - await exactTableLink.click(); - return; - } + for (let attempt = 0; attempt < 8; attempt++) { + if ( + await clickLinkWhenAvailable( + tableBody.getByRole("link", { name: itemName, exact: true }), + ) + ) { + return; + } - const normalizedExactTableLink = tableBody - .getByRole("link", { name: exactNameRegex }) - .first(); - if ((await normalizedExactTableLink.count()) > 0) { - await normalizedExactTableLink.click(); - return; - } + if ( + await clickLinkWhenAvailable( + tableBody.getByRole("link", { name: exactNameRegex }), + ) + ) { + return; + } - const rowWithMatchingLink = tableBody - .locator("tr") - .filter({ has: page.getByRole("link", { name: exactNameRegex }) }) - .first(); - if ((await rowWithMatchingLink.count()) > 0) { - await rowWithMatchingLink - .getByRole("link", { name: exactNameRegex }) - .first() - .click(); - return; - } + if ( + await clickLinkWhenAvailable( + tableBody + .locator("tr") + .filter({ has: page.getByRole("link", { name: exactNameRegex }) }) + .getByRole("link", { name: exactNameRegex }), + ) + ) { + return; + } - const looseTableLink = tableBody - .getByRole("link", { name: itemName }) - .first(); - if ((await looseTableLink.count()) > 0) { - await looseTableLink.click(); - return; + if ( + await clickLinkWhenAvailable( + tableBody.getByRole("link", { name: itemName }), + ) + ) { + return; + } + + await page.waitForTimeout(250); } throw new Error(`Table row item "${itemName}" not found`);