diff --git a/e2e/getting-started.spec.mjs b/e2e/getting-started.spec.mjs new file mode 100644 index 000000000..627c7c510 --- /dev/null +++ b/e2e/getting-started.spec.mjs @@ -0,0 +1,131 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ + +// @ts-check +import { test, expect } from "@playwright/test"; +import { BASE_URL, assertNoErrors, clickDropdownItem, loginToAdmin } from "./helpers.mjs"; + +const IS_GETTING_STARTED = process.env.OPENIDM_SAMPLE === "samples/getting-started"; +const MAPPING_NAME = "HumanResources_Engineering"; +const MAPPING_PROPERTIES_URL = `${BASE_URL}/admin/#properties/${MAPPING_NAME}/`; +const ENGINEERING_LIST_URL = `${BASE_URL}/admin/#resource/system/engineering/account/list/`; + +async function openMappingsPage(page) { + await clickDropdownItem(page, /configure/i, "#mapping/"); + await expect(page.locator(".mapping-config-body").filter({ hasText: MAPPING_NAME }).first()) + .toBeVisible({ timeout: 30000 }); +} + +async function openMappingProperties(page) { + await page.goto(MAPPING_PROPERTIES_URL); + await expect(page.locator("h1")).toContainText(MAPPING_NAME, { timeout: 30000 }); + await page.locator("#propertiesTab").waitFor({ state: "visible", timeout: 30000 }); + await expect(page.locator("#propertiesTab")).toHaveClass(/active/, { timeout: 30000 }); + await expect(page.locator("#attributesGridHolder")).toBeVisible({ timeout: 30000 }); +} + +async function chooseJaneSanchezSample(page) { + // Selectize is initialized on the original ). Instead it inserts + // a `.selectize-control` wrapper next to the now-hidden . + const sampleSourceInput = page.locator( + '.selectize-control input[placeholder="Search to see preview"]' + ).first(); + await sampleSourceInput.waitFor({ state: "visible", timeout: 30000 }); + await sampleSourceInput.click(); + // selectize listens to keydown/keyup events to fire its `load` callback, so + // typing one character at a time (instead of `fill`, which only sets value + + // dispatches a single input event) is required to populate the dropdown. + await sampleSourceInput.pressSequentially("Sanchez", { delay: 80 }); + + const janeOption = page + .locator(".selectize-dropdown .option, .selectize-dropdown .fr-search-option") + .filter({ hasText: /Jane[\s\S]*Sanchez|Sanchez[\s\S]*Jane/i }) + .first(); + await janeOption.waitFor({ state: "visible", timeout: 15000 }); + await janeOption.click(); + + // After selecting the sample source the AttributesGrid re-renders with sample + // values appended in parentheses next to the source/target property cells. + await expect(page.locator("#attributesGridHolder")).toContainText("Jane", { timeout: 30000 }); + await expect(page.locator("#attributesGridHolder")).toContainText("Sanchez", { timeout: 30000 }); + await expect(page.locator("#attributesGridHolder")).toContainText("jsanchez@example.com", { timeout: 30000 }); +} + +test.describe.serial("Getting Started Sample - HumanResources_Engineering UI", () => { + test.skip(!IS_GETTING_STARTED, "Only runs when OPENIDM_SAMPLE=samples/getting-started"); + + test.beforeEach(async ({ page }) => { + await loginToAdmin(page); + }); + + test("Configure > Mappings page lists HumanResources_Engineering", async ({ page }) => { + await openMappingsPage(page); + await assertNoErrors(page); + }); + + test("Open HumanResources_Engineering mapping > Properties tab shows expected attributes", async ({ page }) => { + await openMappingsPage(page); + await page.locator(".mapping-config-body").filter({ hasText: MAPPING_NAME }).first().click(); + + await expect(page).toHaveURL(new RegExp(`/admin/#properties/${MAPPING_NAME}/?$`), { timeout: 30000 }); + await expect(page.locator("#propertiesTab")).toHaveClass(/active/, { timeout: 30000 }); + + for (const attribute of ["firstname", "lastname", "email", "telephoneNumber"]) { + await expect(page.locator("#attributesGridHolder")).toContainText(attribute, { timeout: 30000 }); + } + + await assertNoErrors(page); + }); + + test("Sample Source 'Sanchez' shows Jane Sanchez dropdown and selecting populates preview", async ({ page }) => { + await openMappingProperties(page); + await chooseJaneSanchezSample(page); + await assertNoErrors(page); + }); + + test("Reconcile Now completes successfully with 3 entries", async ({ page }) => { + await openMappingProperties(page); + await chooseJaneSanchezSample(page); + + await page.evaluate(() => window.scrollTo(0, 0)); + await page.locator("#syncNowButton").click(); + + // syncLabel switches to the "Last reconciled" / "Completed" translation when + // the recon ends successfully (see MappingBaseView.setReconEnded). + await expect(page.locator("#syncLabel")).toContainText(/completed/i, { timeout: 120000 }); + + // Expand the sync details widget so the entry counters render. + await page.locator("#syncStatus").click(); + await expect(page.locator("#syncStatusDetails")).toBeVisible({ timeout: 30000 }); + await expect(page.locator("#syncStatusDetails")).toContainText(/success/i, { timeout: 30000 }); + await expect(page.locator("#syncStatusDetails .success-display.display-number")) + .toHaveText("3", { timeout: 30000 }); + + await assertNoErrors(page); + }); + + test("Reconciled Jane Sanchez appears in Manage > engineering system list", async ({ page }) => { + await page.goto(ENGINEERING_LIST_URL); + await expect(page.locator(".page-header h1")).toContainText(/engineering/i, { timeout: 30000 }); + await expect(page.locator(".backgrid.table")).toContainText(/Sanchez/i, { timeout: 30000 }); + await expect(page.locator(".backgrid.table")) + .toContainText(/Jane|jsanchez@example\.com/i, { timeout: 30000 }); + await assertNoErrors(page); + }); +}); diff --git a/e2e/helpers.mjs b/e2e/helpers.mjs new file mode 100644 index 000000000..46e02fe4a --- /dev/null +++ b/e2e/helpers.mjs @@ -0,0 +1,97 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ + +// @ts-check +import { expect } from "@playwright/test"; + +export const BASE_URL = process.env.OPENIDM_URL || "http://localhost:8080"; +export const CONTEXT_PATH = process.env.OPENIDM_CONTEXT_PATH || "/openidm"; +export const ADMIN_USER = process.env.OPENIDM_ADMIN_USER || "openidm-admin"; +export const ADMIN_PASS = process.env.OPENIDM_ADMIN_PASS || "openidm-admin"; + +/** Log in to the Admin UI and wait for the navigation bar to appear. */ +export async function loginToAdmin(page) { + await page.goto(`${BASE_URL}/admin/`); + await page.waitForSelector("#login", { timeout: 30000 }); + await page.fill("#login", ADMIN_USER); + await page.fill("#password", ADMIN_PASS); + await page.click("[type=submit], .btn-primary"); + // Wait for the first dropdown toggle to appear (signals post-login render started). + await page.waitForSelector(".navbar-nav a.dropdown-toggle", { + state: "visible", + timeout: 60000, + }); + // Configure and Manage toggles are populated by additional async REST calls and + // may render significantly later than Dashboards in slow CI environments. + await Promise.all([ + page.locator(".navbar-nav a.dropdown-toggle").filter({ hasText: /configure/i }) + .waitFor({ state: "visible", timeout: 90000 }), + page.locator(".navbar-nav a.dropdown-toggle").filter({ hasText: /manage/i }) + .waitFor({ state: "visible", timeout: 90000 }), + ]); +} + +/** Log in to the Enduser UI and wait for the navigation bar to appear. */ +export async function loginToEnduser(page) { + await page.goto(`${BASE_URL}/`); + await page.waitForSelector("#login", { timeout: 30000 }); + await page.fill("#login", ADMIN_USER); + await page.fill("#password", ADMIN_PASS); + await page.click("[type=submit], .btn-primary"); + await page.waitForFunction( + () => document.querySelector("#content") !== null || document.querySelector(".navbar") !== null, + { timeout: 30000 } + ); +} + +/** Assert that no visible .alert-danger elements are present on the page. */ +export async function assertNoErrors(page) { + // Allow up to 3 s for transient notification banners to auto-dismiss before + // we check. Bootstrap alert-danger elements rendered by the Messages module + // are hidden via display:none (causing offsetParent to be null) when gone. + await page.waitForFunction( + () => [...document.querySelectorAll(".alert-danger")] + .every(el => !el.offsetParent), + { timeout: 3000 } + ).catch(() => { /* persistent alerts will be caught by the check below */ }); + + const alertDangerLocator = page.locator(".alert-danger"); + const count = await alertDangerLocator.count(); + let visibleErrors = 0; + for (let i = 0; i < count; i++) { + if (await alertDangerLocator.nth(i).isVisible()) { + visibleErrors++; + } + } + expect(visibleErrors).toBe(0); +} + +/** + * Open a navbar dropdown by its visible text label and then click a sub-item + * identified by its href attribute. Waits for the sub-item to become visible + * before clicking so the dropdown animation has completed. + */ +export async function clickDropdownItem(page, dropdownLabel, itemHref) { + const toggle = page + .locator(".navbar-nav a.dropdown-toggle") + .filter({ hasText: dropdownLabel }); + await toggle.waitFor({ state: "visible", timeout: 30000 }); + await toggle.click(); + const item = page.locator(`.dropdown-menu a[href="${itemHref}"]`).first(); + await item.waitFor({ state: "visible", timeout: 15000 }); + await item.click(); + await page.waitForLoadState("networkidle"); +} diff --git a/e2e/ui-smoke-test.spec.mjs b/e2e/ui-smoke-test.spec.mjs index d5e58afef..7e4d7aa0e 100644 --- a/e2e/ui-smoke-test.spec.mjs +++ b/e2e/ui-smoke-test.spec.mjs @@ -16,85 +16,16 @@ // @ts-check import { test, expect } from "@playwright/test"; - -const BASE_URL = process.env.OPENIDM_URL || "http://localhost:8080"; -const CONTEXT_PATH = process.env.OPENIDM_CONTEXT_PATH || "/openidm"; -const ADMIN_USER = process.env.OPENIDM_ADMIN_USER || "openidm-admin"; -const ADMIN_PASS = process.env.OPENIDM_ADMIN_PASS || "openidm-admin"; - -/** Log in to the Admin UI and wait for the navigation bar to appear. */ -async function loginToAdmin(page) { - await page.goto(`${BASE_URL}/admin/`); - await page.waitForSelector("#login", { timeout: 30000 }); - await page.fill("#login", ADMIN_USER); - await page.fill("#password", ADMIN_PASS); - await page.click("[type=submit], .btn-primary"); - // Wait for the first dropdown toggle to appear (signals post-login render started). - await page.waitForSelector(".navbar-nav a.dropdown-toggle", { - state: "visible", - timeout: 60000, - }); - // Configure and Manage toggles are populated by additional async REST calls and - // may render significantly later than Dashboards in slow CI environments. - await Promise.all([ - page.locator(".navbar-nav a.dropdown-toggle").filter({ hasText: /configure/i }) - .waitFor({ state: "visible", timeout: 90000 }), - page.locator(".navbar-nav a.dropdown-toggle").filter({ hasText: /manage/i }) - .waitFor({ state: "visible", timeout: 90000 }), - ]); -} - -/** Log in to the Enduser UI and wait for the navigation bar to appear. */ -async function loginToEnduser(page) { - await page.goto(`${BASE_URL}/`); - await page.waitForSelector("#login", { timeout: 30000 }); - await page.fill("#login", ADMIN_USER); - await page.fill("#password", ADMIN_PASS); - await page.click("[type=submit], .btn-primary"); - await page.waitForFunction( - () => document.querySelector("#content") !== null || document.querySelector(".navbar") !== null, - { timeout: 30000 } - ); -} - -/** Assert that no visible .alert-danger elements are present on the page. */ -async function assertNoErrors(page) { - // Allow up to 3 s for transient notification banners to auto-dismiss before - // we check. Bootstrap alert-danger elements rendered by the Messages module - // are hidden via display:none (causing offsetParent to be null) when gone. - await page.waitForFunction( - () => [...document.querySelectorAll(".alert-danger")] - .every(el => !el.offsetParent), - { timeout: 3000 } - ).catch(() => { /* persistent alerts will be caught by the check below */ }); - - const alertDangerLocator = page.locator(".alert-danger"); - const count = await alertDangerLocator.count(); - let visibleErrors = 0; - for (let i = 0; i < count; i++) { - if (await alertDangerLocator.nth(i).isVisible()) { - visibleErrors++; - } - } - expect(visibleErrors).toBe(0); -} - -/** - * Open a navbar dropdown by its visible text label and then click a sub-item - * identified by its href attribute. Waits for the sub-item to become visible - * before clicking so the dropdown animation has completed. - */ -async function clickDropdownItem(page, dropdownLabel, itemHref) { - const toggle = page - .locator(".navbar-nav a.dropdown-toggle") - .filter({ hasText: dropdownLabel }); - await toggle.waitFor({ state: "visible", timeout: 30000 }); - await toggle.click(); - const item = page.locator(`.dropdown-menu a[href="${itemHref}"]`).first(); - await item.waitFor({ state: "visible", timeout: 15000 }); - await item.click(); - await page.waitForLoadState("networkidle"); -} +import { + ADMIN_PASS, + ADMIN_USER, + BASE_URL, + CONTEXT_PATH, + assertNoErrors, + clickDropdownItem, + loginToAdmin, + loginToEnduser, +} from "./helpers.mjs"; test.describe("OpenIDM UI Smoke Tests", () => {