Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions e2e/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,15 @@ export async function loginToAdmin(page) {

/** Log in to the Enduser UI and wait for the navigation bar to appear. */
export async function loginToEnduser(page) {
await loginToEnduserAs(page, ADMIN_USER, ADMIN_PASS);
}

/** Log in to the Enduser UI as the supplied user and wait for the shell to render. */
export async function loginToEnduserAs(page, username, password) {
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.fill("#login", username);
await page.fill("#password", password);
await page.click("[type=submit], .btn-primary");
await page.waitForFunction(
() => document.querySelector("#content") !== null || document.querySelector(".navbar") !== null,
Expand Down Expand Up @@ -79,6 +84,49 @@ export async function assertNoErrors(page) {
expect(visibleErrors).toBe(0);
}

/**
* Run "Reconcile Now" for the given mapping by navigating directly to its
* properties page (#properties/<name>/) and clicking #syncNowButton. Waits for
* the syncLabel to switch to the "completed" translation, then expands the
* sync status widget. If `expectedSuccessCount` is provided, asserts the
* .success-display counter equals that number (as a string); otherwise just
* verifies that the details panel mentions "success".
*/
export async function runReconcileNow(page, mappingName, expectedSuccessCount) {
await page.goto(`${BASE_URL}/admin/#properties/${mappingName}/`);
await expect(page.locator("h1")).toContainText(mappingName, { timeout: 30000 });
await page.locator("#propertiesTab").waitFor({ state: "visible", timeout: 30000 });
await page.locator("#syncNowButton").waitFor({ state: "visible", timeout: 30000 });
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: 180000 });

// Expand the sync details widget so the entry counters render. The
// syncStatus toggle is a collapse trigger and a single click is sometimes
// swallowed by overlapping in-flight progress markup, so retry until the
// details pane is visible.
const syncStatus = page.locator("#syncStatus");
const syncDetails = page.locator("#syncStatusDetails");
await syncStatus.scrollIntoViewIfNeeded();
for (let i = 0; i < 5; i++) {
if (await syncDetails.isVisible()) {
break;
}
await syncStatus.click({ force: true }).catch(() => {});
await page.waitForTimeout(1000);
}
await expect(syncDetails).toBeVisible({ timeout: 30000 });
await expect(syncDetails).toContainText(/success/i, { timeout: 30000 });
if (typeof expectedSuccessCount === "number") {
await expect(syncDetails.locator(".success-display.display-number"))
.toHaveText(String(expectedSuccessCount), { timeout: 30000 });
}
}

/**
* 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
Expand Down
269 changes: 269 additions & 0 deletions e2e/workflow.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/*
* 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
//
// End-to-end UI smoke tests for samples/workflow. Test names mirror the
// numbered steps from openidm-zip/src/main/resources/samples/workflow/README
// so any failure maps 1-to-1 onto the documented walk-through.
//
import { test, expect } from "@playwright/test";
import {
ADMIN_PASS,
ADMIN_USER,
BASE_URL,
CONTEXT_PATH,
assertNoErrors,
clickDropdownItem,
loginToAdmin,
loginToEnduserAs,
runReconcileNow,
} from "./helpers.mjs";

const IS_WORKFLOW = process.env.OPENIDM_SAMPLE === "samples/workflow";

const MAPPING_ROLES = "systemRolesFileRole_managedRole";
const MAPPING_USERS_IN = "systemXmlfileAccounts_managedUser";
const MAPPING_USERS_OUT = "managedUser_systemXmlfileAccounts";

const ROLES_LIST_URL = `${BASE_URL}/admin/#resource/managed/role/list/`;
const USERS_LIST_URL = `${BASE_URL}/admin/#resource/managed/user/list/`;
const PROCESSES_URL = `${BASE_URL}/admin/#workflow/processes/`;
const SETTINGS_URL = `${BASE_URL}/admin/#settings/`;

// Unique identifier for the new contractor created during step 6, so the
// workflow can be re-run idempotently across local repeats.
const CONTRACTOR_USERNAME = `contractor_${Date.now()}`;
const CONTRACTOR_EMAIL = `${CONTRACTOR_USERNAME}@example.invalid`;

async function openMappingsPage(page) {
await clickDropdownItem(page, /configure/i, "#mapping/");
await expect(page.locator(".mapping-config-body").first())
.toBeVisible({ timeout: 30000 });
}

async function clearSession(page) {
await page.context().clearCookies();
}

// ---------------------------------------------------------------------------
// Steps 1-5: Admin UI walk-through
// ---------------------------------------------------------------------------
test.describe.serial("Workflow Sample - Admin UI walk-through", () => {
test.skip(!IS_WORKFLOW, "Only runs when OPENIDM_SAMPLE=samples/workflow");

test.beforeEach(async ({ page }) => {
await loginToAdmin(page);
});

test("Step 1) Configure the connection to your email server", async ({ page }) => {
// README: Configure -> System Preferences -> Email.
// Settings is a tabbed view; navigate directly to the email sub-route so
// the #emailContainer tab pane is the active one. Real SMTP credentials
// are not pushed in CI; we only verify the panel renders.
await page.goto(`${BASE_URL}/admin/#settings/email/`);
const emailTab = page.locator('a[href="#emailContainer"]').first();
if (await emailTab.count()) {
await emailTab.click().catch(() => { /* tab may already be active */ });
}
await expect(page.locator("#emailContainer")).toBeVisible({ timeout: 30000 });
await expect(page.locator("#emailContainer")).toContainText(/email/i, { timeout: 30000 });
await assertNoErrors(page);
});

test("Step 2) Run reconciliation for roles and users", async ({ page }) => {
// 2a) Configure -> Mappings shows all three workflow-sample mappings.
await openMappingsPage(page);
for (const mapping of [MAPPING_ROLES, MAPPING_USERS_IN, MAPPING_USERS_OUT]) {
await expect(
page.locator(".mapping-config-body").filter({ hasText: mapping }).first()
).toBeVisible({ timeout: 30000 });
}

// 2b) systemRolesFileRole_managedRole -> creates two managed/role entries.
await runReconcileNow(page, MAPPING_ROLES, 2);
// 2c) systemXmlfileAccounts_managedUser -> first pass creates top-level managers.
await runReconcileNow(page, MAPPING_USERS_IN);
// 2d) systemXmlfileAccounts_managedUser -> second pass creates the employees.
await runReconcileNow(page, MAPPING_USERS_IN);

await assertNoErrors(page);
});

test("Step 3) View the newly-created data", async ({ page }) => {
// Manage -> Role list contains "employee" and "manager".
await page.goto(ROLES_LIST_URL);
await expect(page.locator(".page-header h1")).toContainText(/role/i, { timeout: 30000 });
await expect(page.locator(".backgrid.table")).toContainText("employee", { timeout: 30000 });
await expect(page.locator(".backgrid.table")).toContainText("manager", { timeout: 30000 });

// Manage -> User list contains "manager1" and "user1".
await page.goto(USERS_LIST_URL);
await expect(page.locator(".page-header h1")).toContainText(/user/i, { timeout: 30000 });
await expect(page.locator(".backgrid.table")).toContainText("user1", { timeout: 30000 });
await expect(page.locator(".backgrid.table")).toContainText("manager1", { timeout: 30000 });

await assertNoErrors(page);
});

test("Step 4) Note the workflows available to initiate", async ({ page }) => {
// README: Manage -> Processes -> Definitions, "Contractor onboarding process".
await page.goto(PROCESSES_URL);
await expect(page.locator(".page-header h1")).toBeVisible({ timeout: 30000 });
await page.locator('#processTabs a[href="#processDefinitions"]')
.waitFor({ state: "visible", timeout: 30000 });
await page.locator('#processTabs a[href="#processDefinitions"]').click();
await expect(page.locator("#processDefinitions"))
.toContainText(/Contractor onboarding process/i, { timeout: 60000 });
await assertNoErrors(page);
});

test("Step 5) Log out of Admin UI", async ({ page }) => {
// README: click upper-right silhouette -> "Log Out".
await page.goto(`${BASE_URL}/admin/#dashboard/`);
await page.waitForLoadState("networkidle");
const userToggle = page
.locator(".navbar-nav .dropdown-toggle .fa-user, .navbar-nav .user-dropdown")
.first();
if (await userToggle.count()) {
await userToggle.click().catch(() => { /* fall through to direct logout URL */ });
}
const logoutLink = page.locator('a[href="#logout/"]').first();
if (await logoutLink.count()) {
await logoutLink.click();
} else {
await page.goto(`${BASE_URL}/admin/#logout/`);
}
// After logging out the login form must be visible again.
await page.waitForSelector("#login", { timeout: 30000 });
});
});

// ---------------------------------------------------------------------------
// Steps 6-8: Self-Service UI walk-through (depends on Step 2 having created
// user1 and manager1 in the same OpenIDM instance, which the CI smoke job
// guarantees by running the specs sequentially against one deployment).
// ---------------------------------------------------------------------------
test.describe.serial("Workflow Sample - Self-Service UI walk-through", () => {
test.skip(!IS_WORKFLOW, "Only runs when OPENIDM_SAMPLE=samples/workflow");

test("Step 6) Initiate workflow process as user1 / Welcome1", async ({ page }) => {
await loginToEnduserAs(page, "user1", "Welcome1");
await page.goto(`${BASE_URL}/#dashboard/`);
await page.waitForLoadState("networkidle");

// Processes panel renders <li class="process-item"> per workflow definition.
const processItem = page.locator("li.process-item")
.filter({ hasText: /Contractor onboarding process/i })
.first();
await expect(processItem).toBeVisible({ timeout: 60000 });
await processItem.locator("a.details-link").click();

// Fill the start-event form (fields from contractorOnboarding.bpmn20.xml).
const today = new Date().toISOString().slice(0, 10);
const future = new Date(Date.now() + 30 * 86400_000).toISOString().slice(0, 10);
const fields = {
userName: CONTRACTOR_USERNAME,
givenName: "Cont",
sn: "Ractor",
mail: CONTRACTOR_EMAIL,
startDate: today,
endDate: future,
description: "Created by workflow smoke test",
};
for (const [name, value] of Object.entries(fields)) {
const input = page.locator(`#processContent [name="${name}"]`).first();
await input.waitFor({ state: "visible", timeout: 30000 });
await input.fill(value);
}

await page.locator('input[name="startProcessButton"]').first().click();
await page.waitForLoadState("networkidle");
await assertNoErrors(page);
});

test("Step 7) Approve workflow task as manager1 / Welcome1", async ({ page, request }) => {
await clearSession(page);
await loginToEnduserAs(page, "manager1", "Welcome1");
await page.goto(`${BASE_URL}/#dashboard/`);
await page.waitForLoadState("networkidle");

// Locate "Approve Contractor" in My Group's Tasks (or My Tasks if claimed).
const candidateTask = page.locator("#candidateTasks li, #myTasks li")
.filter({ hasText: /Approve Contractor/i })
.first();
await expect(candidateTask).toBeVisible({ timeout: 60000 });

// Claim the task via "Assign to Me" if still unassigned.
const assignSelect = candidateTask.locator('select[name="assignedUser"]');
if (await assignSelect.count()) {
await assignSelect.selectOption("me").catch(() => { /* may be claimed already */ });
await page.waitForLoadState("networkidle");
}

// After claim the task moves into #myTasks; re-locate before opening details.
const myTask = page.locator("#myTasks li")
.filter({ hasText: /Approve Contractor/i })
.first();
await expect(myTask).toBeVisible({ timeout: 60000 });
await myTask.locator("a.details-link").click();

// Set Decision = Accept and Complete the task.
const decision = page.locator('[name="decision"]').first();
await decision.waitFor({ state: "visible", timeout: 30000 });
await decision.selectOption({ label: "Accept" }).catch(async () => {
await decision.selectOption("accept");
});
await page.locator('input[name="saveButton"]').first().click();
await page.waitForLoadState("networkidle");

// Verify the contractor was created in managed/user (the createManagedUser
// script task runs immediately after Accept). REST is used here so this
// assertion is independent of the SMTP-dependent Accept Notice step.
// The workflow engine runs the post-approval script tasks asynchronously,
// so poll the managed/user endpoint until the contractor appears.
const filter = encodeURIComponent(`/userName eq "${CONTRACTOR_USERNAME}"`);
const lookupUrl = `${BASE_URL}${CONTEXT_PATH}/managed/user?_queryFilter=${filter}`;
const headers = { "X-OpenIDM-Username": ADMIN_USER, "X-OpenIDM-Password": ADMIN_PASS };
let resultCount = 0;
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
const resp = await request.get(lookupUrl, { headers });
expect(resp.status()).toBe(200);
const body = await resp.json();
resultCount = body.resultCount || 0;
if (resultCount >= 1) break;
await page.waitForTimeout(2000);
}
expect(
resultCount,
`contractor ${CONTRACTOR_USERNAME} should exist after approval`
).toBeGreaterThanOrEqual(1);
});

test("Step 8) Reset your password and login", async ({ page }) => {
// The reset email is dispatched by the workflow's "Accept Notice" script
// and requires real SMTP -- not configured in CI. We instead verify the
// Self-Service password-reset entry point is reachable, so a contractor
// who did receive the email could complete the flow.
await clearSession(page);
await page.goto(`${BASE_URL}/#passwordReset/`);
await page.waitForLoadState("networkidle");
await expect(page.locator("body")).toContainText(/password/i, { timeout: 30000 });
await assertNoErrors(page);
});
});