Skip to content

Commit a146dcd

Browse files
authored
Adding extended UI smoke tests for the samples/workflow scenario (#172)
1 parent 7cdd813 commit a146dcd

2 files changed

Lines changed: 319 additions & 2 deletions

File tree

e2e/helpers.mjs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@ export async function loginToAdmin(page) {
4646

4747
/** Log in to the Enduser UI and wait for the navigation bar to appear. */
4848
export async function loginToEnduser(page) {
49+
await loginToEnduserAs(page, ADMIN_USER, ADMIN_PASS);
50+
}
51+
52+
/** Log in to the Enduser UI as the supplied user and wait for the shell to render. */
53+
export async function loginToEnduserAs(page, username, password) {
4954
await page.goto(`${BASE_URL}/`);
5055
await page.waitForSelector("#login", { timeout: 30000 });
51-
await page.fill("#login", ADMIN_USER);
52-
await page.fill("#password", ADMIN_PASS);
56+
await page.fill("#login", username);
57+
await page.fill("#password", password);
5358
await page.click("[type=submit], .btn-primary");
5459
await page.waitForFunction(
5560
() => document.querySelector("#content") !== null || document.querySelector(".navbar") !== null,
@@ -79,6 +84,49 @@ export async function assertNoErrors(page) {
7984
expect(visibleErrors).toBe(0);
8085
}
8186

87+
/**
88+
* Run "Reconcile Now" for the given mapping by navigating directly to its
89+
* properties page (#properties/<name>/) and clicking #syncNowButton. Waits for
90+
* the syncLabel to switch to the "completed" translation, then expands the
91+
* sync status widget. If `expectedSuccessCount` is provided, asserts the
92+
* .success-display counter equals that number (as a string); otherwise just
93+
* verifies that the details panel mentions "success".
94+
*/
95+
export async function runReconcileNow(page, mappingName, expectedSuccessCount) {
96+
await page.goto(`${BASE_URL}/admin/#properties/${mappingName}/`);
97+
await expect(page.locator("h1")).toContainText(mappingName, { timeout: 30000 });
98+
await page.locator("#propertiesTab").waitFor({ state: "visible", timeout: 30000 });
99+
await page.locator("#syncNowButton").waitFor({ state: "visible", timeout: 30000 });
100+
await page.evaluate(() => window.scrollTo(0, 0));
101+
await page.locator("#syncNowButton").click();
102+
103+
// syncLabel switches to the "Last reconciled" / "Completed" translation when
104+
// the recon ends successfully (see MappingBaseView.setReconEnded).
105+
await expect(page.locator("#syncLabel"))
106+
.toContainText(/completed/i, { timeout: 180000 });
107+
108+
// Expand the sync details widget so the entry counters render. The
109+
// syncStatus toggle is a collapse trigger and a single click is sometimes
110+
// swallowed by overlapping in-flight progress markup, so retry until the
111+
// details pane is visible.
112+
const syncStatus = page.locator("#syncStatus");
113+
const syncDetails = page.locator("#syncStatusDetails");
114+
await syncStatus.scrollIntoViewIfNeeded();
115+
for (let i = 0; i < 5; i++) {
116+
if (await syncDetails.isVisible()) {
117+
break;
118+
}
119+
await syncStatus.click({ force: true }).catch(() => {});
120+
await page.waitForTimeout(1000);
121+
}
122+
await expect(syncDetails).toBeVisible({ timeout: 30000 });
123+
await expect(syncDetails).toContainText(/success/i, { timeout: 30000 });
124+
if (typeof expectedSuccessCount === "number") {
125+
await expect(syncDetails.locator(".success-display.display-number"))
126+
.toHaveText(String(expectedSuccessCount), { timeout: 30000 });
127+
}
128+
}
129+
82130
/**
83131
* Open a navbar dropdown by its visible text label and then click a sub-item
84132
* identified by its href attribute. Waits for the sub-item to become visible

e2e/workflow.spec.mjs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* The contents of this file are subject to the terms of the Common Development and
3+
* Distribution License (the License). You may not use this file except in compliance with the
4+
* License.
5+
*
6+
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7+
* specific language governing permission and limitations under the License.
8+
*
9+
* When distributing Covered Software, include this CDDL Header Notice in each file and include
10+
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11+
* Header, with the fields enclosed by brackets [] replaced by your own identifying
12+
* information: "Portions copyright [year] [name of copyright owner]".
13+
*
14+
* Copyright 2026 3A Systems, LLC.
15+
*/
16+
17+
// @ts-check
18+
//
19+
// End-to-end UI smoke tests for samples/workflow. Test names mirror the
20+
// numbered steps from openidm-zip/src/main/resources/samples/workflow/README
21+
// so any failure maps 1-to-1 onto the documented walk-through.
22+
//
23+
import { test, expect } from "@playwright/test";
24+
import {
25+
ADMIN_PASS,
26+
ADMIN_USER,
27+
BASE_URL,
28+
CONTEXT_PATH,
29+
assertNoErrors,
30+
clickDropdownItem,
31+
loginToAdmin,
32+
loginToEnduserAs,
33+
runReconcileNow,
34+
} from "./helpers.mjs";
35+
36+
const IS_WORKFLOW = process.env.OPENIDM_SAMPLE === "samples/workflow";
37+
38+
const MAPPING_ROLES = "systemRolesFileRole_managedRole";
39+
const MAPPING_USERS_IN = "systemXmlfileAccounts_managedUser";
40+
const MAPPING_USERS_OUT = "managedUser_systemXmlfileAccounts";
41+
42+
const ROLES_LIST_URL = `${BASE_URL}/admin/#resource/managed/role/list/`;
43+
const USERS_LIST_URL = `${BASE_URL}/admin/#resource/managed/user/list/`;
44+
const PROCESSES_URL = `${BASE_URL}/admin/#workflow/processes/`;
45+
const SETTINGS_URL = `${BASE_URL}/admin/#settings/`;
46+
47+
// Unique identifier for the new contractor created during step 6, so the
48+
// workflow can be re-run idempotently across local repeats.
49+
const CONTRACTOR_USERNAME = `contractor_${Date.now()}`;
50+
const CONTRACTOR_EMAIL = `${CONTRACTOR_USERNAME}@example.invalid`;
51+
52+
async function openMappingsPage(page) {
53+
await clickDropdownItem(page, /configure/i, "#mapping/");
54+
await expect(page.locator(".mapping-config-body").first())
55+
.toBeVisible({ timeout: 30000 });
56+
}
57+
58+
async function clearSession(page) {
59+
await page.context().clearCookies();
60+
}
61+
62+
// ---------------------------------------------------------------------------
63+
// Steps 1-5: Admin UI walk-through
64+
// ---------------------------------------------------------------------------
65+
test.describe.serial("Workflow Sample - Admin UI walk-through", () => {
66+
test.skip(!IS_WORKFLOW, "Only runs when OPENIDM_SAMPLE=samples/workflow");
67+
68+
test.beforeEach(async ({ page }) => {
69+
await loginToAdmin(page);
70+
});
71+
72+
test("Step 1) Configure the connection to your email server", async ({ page }) => {
73+
// README: Configure -> System Preferences -> Email.
74+
// Settings is a tabbed view; navigate directly to the email sub-route so
75+
// the #emailContainer tab pane is the active one. Real SMTP credentials
76+
// are not pushed in CI; we only verify the panel renders.
77+
await page.goto(`${BASE_URL}/admin/#settings/email/`);
78+
const emailTab = page.locator('a[href="#emailContainer"]').first();
79+
if (await emailTab.count()) {
80+
await emailTab.click().catch(() => { /* tab may already be active */ });
81+
}
82+
await expect(page.locator("#emailContainer")).toBeVisible({ timeout: 30000 });
83+
await expect(page.locator("#emailContainer")).toContainText(/email/i, { timeout: 30000 });
84+
await assertNoErrors(page);
85+
});
86+
87+
test("Step 2) Run reconciliation for roles and users", async ({ page }) => {
88+
// 2a) Configure -> Mappings shows all three workflow-sample mappings.
89+
await openMappingsPage(page);
90+
for (const mapping of [MAPPING_ROLES, MAPPING_USERS_IN, MAPPING_USERS_OUT]) {
91+
await expect(
92+
page.locator(".mapping-config-body").filter({ hasText: mapping }).first()
93+
).toBeVisible({ timeout: 30000 });
94+
}
95+
96+
// 2b) systemRolesFileRole_managedRole -> creates two managed/role entries.
97+
await runReconcileNow(page, MAPPING_ROLES, 2);
98+
// 2c) systemXmlfileAccounts_managedUser -> first pass creates top-level managers.
99+
await runReconcileNow(page, MAPPING_USERS_IN);
100+
// 2d) systemXmlfileAccounts_managedUser -> second pass creates the employees.
101+
await runReconcileNow(page, MAPPING_USERS_IN);
102+
103+
await assertNoErrors(page);
104+
});
105+
106+
test("Step 3) View the newly-created data", async ({ page }) => {
107+
// Manage -> Role list contains "employee" and "manager".
108+
await page.goto(ROLES_LIST_URL);
109+
await expect(page.locator(".page-header h1")).toContainText(/role/i, { timeout: 30000 });
110+
await expect(page.locator(".backgrid.table")).toContainText("employee", { timeout: 30000 });
111+
await expect(page.locator(".backgrid.table")).toContainText("manager", { timeout: 30000 });
112+
113+
// Manage -> User list contains "manager1" and "user1".
114+
await page.goto(USERS_LIST_URL);
115+
await expect(page.locator(".page-header h1")).toContainText(/user/i, { timeout: 30000 });
116+
await expect(page.locator(".backgrid.table")).toContainText("user1", { timeout: 30000 });
117+
await expect(page.locator(".backgrid.table")).toContainText("manager1", { timeout: 30000 });
118+
119+
await assertNoErrors(page);
120+
});
121+
122+
test("Step 4) Note the workflows available to initiate", async ({ page }) => {
123+
// README: Manage -> Processes -> Definitions, "Contractor onboarding process".
124+
await page.goto(PROCESSES_URL);
125+
await expect(page.locator(".page-header h1")).toBeVisible({ timeout: 30000 });
126+
await page.locator('#processTabs a[href="#processDefinitions"]')
127+
.waitFor({ state: "visible", timeout: 30000 });
128+
await page.locator('#processTabs a[href="#processDefinitions"]').click();
129+
await expect(page.locator("#processDefinitions"))
130+
.toContainText(/Contractor onboarding process/i, { timeout: 60000 });
131+
await assertNoErrors(page);
132+
});
133+
134+
test("Step 5) Log out of Admin UI", async ({ page }) => {
135+
// README: click upper-right silhouette -> "Log Out".
136+
await page.goto(`${BASE_URL}/admin/#dashboard/`);
137+
await page.waitForLoadState("networkidle");
138+
const userToggle = page
139+
.locator(".navbar-nav .dropdown-toggle .fa-user, .navbar-nav .user-dropdown")
140+
.first();
141+
if (await userToggle.count()) {
142+
await userToggle.click().catch(() => { /* fall through to direct logout URL */ });
143+
}
144+
const logoutLink = page.locator('a[href="#logout/"]').first();
145+
if (await logoutLink.count()) {
146+
await logoutLink.click();
147+
} else {
148+
await page.goto(`${BASE_URL}/admin/#logout/`);
149+
}
150+
// After logging out the login form must be visible again.
151+
await page.waitForSelector("#login", { timeout: 30000 });
152+
});
153+
});
154+
155+
// ---------------------------------------------------------------------------
156+
// Steps 6-8: Self-Service UI walk-through (depends on Step 2 having created
157+
// user1 and manager1 in the same OpenIDM instance, which the CI smoke job
158+
// guarantees by running the specs sequentially against one deployment).
159+
// ---------------------------------------------------------------------------
160+
test.describe.serial("Workflow Sample - Self-Service UI walk-through", () => {
161+
test.skip(!IS_WORKFLOW, "Only runs when OPENIDM_SAMPLE=samples/workflow");
162+
163+
test("Step 6) Initiate workflow process as user1 / Welcome1", async ({ page }) => {
164+
await loginToEnduserAs(page, "user1", "Welcome1");
165+
await page.goto(`${BASE_URL}/#dashboard/`);
166+
await page.waitForLoadState("networkidle");
167+
168+
// Processes panel renders <li class="process-item"> per workflow definition.
169+
const processItem = page.locator("li.process-item")
170+
.filter({ hasText: /Contractor onboarding process/i })
171+
.first();
172+
await expect(processItem).toBeVisible({ timeout: 60000 });
173+
await processItem.locator("a.details-link").click();
174+
175+
// Fill the start-event form (fields from contractorOnboarding.bpmn20.xml).
176+
const today = new Date().toISOString().slice(0, 10);
177+
const future = new Date(Date.now() + 30 * 86400_000).toISOString().slice(0, 10);
178+
const fields = {
179+
userName: CONTRACTOR_USERNAME,
180+
givenName: "Cont",
181+
sn: "Ractor",
182+
mail: CONTRACTOR_EMAIL,
183+
startDate: today,
184+
endDate: future,
185+
description: "Created by workflow smoke test",
186+
};
187+
for (const [name, value] of Object.entries(fields)) {
188+
const input = page.locator(`#processContent [name="${name}"]`).first();
189+
await input.waitFor({ state: "visible", timeout: 30000 });
190+
await input.fill(value);
191+
}
192+
193+
await page.locator('input[name="startProcessButton"]').first().click();
194+
await page.waitForLoadState("networkidle");
195+
await assertNoErrors(page);
196+
});
197+
198+
test("Step 7) Approve workflow task as manager1 / Welcome1", async ({ page, request }) => {
199+
await clearSession(page);
200+
await loginToEnduserAs(page, "manager1", "Welcome1");
201+
await page.goto(`${BASE_URL}/#dashboard/`);
202+
await page.waitForLoadState("networkidle");
203+
204+
// Locate "Approve Contractor" in My Group's Tasks (or My Tasks if claimed).
205+
const candidateTask = page.locator("#candidateTasks li, #myTasks li")
206+
.filter({ hasText: /Approve Contractor/i })
207+
.first();
208+
await expect(candidateTask).toBeVisible({ timeout: 60000 });
209+
210+
// Claim the task via "Assign to Me" if still unassigned.
211+
const assignSelect = candidateTask.locator('select[name="assignedUser"]');
212+
if (await assignSelect.count()) {
213+
await assignSelect.selectOption("me").catch(() => { /* may be claimed already */ });
214+
await page.waitForLoadState("networkidle");
215+
}
216+
217+
// After claim the task moves into #myTasks; re-locate before opening details.
218+
const myTask = page.locator("#myTasks li")
219+
.filter({ hasText: /Approve Contractor/i })
220+
.first();
221+
await expect(myTask).toBeVisible({ timeout: 60000 });
222+
await myTask.locator("a.details-link").click();
223+
224+
// Set Decision = Accept and Complete the task.
225+
const decision = page.locator('[name="decision"]').first();
226+
await decision.waitFor({ state: "visible", timeout: 30000 });
227+
await decision.selectOption({ label: "Accept" }).catch(async () => {
228+
await decision.selectOption("accept");
229+
});
230+
await page.locator('input[name="saveButton"]').first().click();
231+
await page.waitForLoadState("networkidle");
232+
233+
// Verify the contractor was created in managed/user (the createManagedUser
234+
// script task runs immediately after Accept). REST is used here so this
235+
// assertion is independent of the SMTP-dependent Accept Notice step.
236+
// The workflow engine runs the post-approval script tasks asynchronously,
237+
// so poll the managed/user endpoint until the contractor appears.
238+
const filter = encodeURIComponent(`/userName eq "${CONTRACTOR_USERNAME}"`);
239+
const lookupUrl = `${BASE_URL}${CONTEXT_PATH}/managed/user?_queryFilter=${filter}`;
240+
const headers = { "X-OpenIDM-Username": ADMIN_USER, "X-OpenIDM-Password": ADMIN_PASS };
241+
let resultCount = 0;
242+
const deadline = Date.now() + 60000;
243+
while (Date.now() < deadline) {
244+
const resp = await request.get(lookupUrl, { headers });
245+
expect(resp.status()).toBe(200);
246+
const body = await resp.json();
247+
resultCount = body.resultCount || 0;
248+
if (resultCount >= 1) break;
249+
await page.waitForTimeout(2000);
250+
}
251+
expect(
252+
resultCount,
253+
`contractor ${CONTRACTOR_USERNAME} should exist after approval`
254+
).toBeGreaterThanOrEqual(1);
255+
});
256+
257+
test("Step 8) Reset your password and login", async ({ page }) => {
258+
// The reset email is dispatched by the workflow's "Accept Notice" script
259+
// and requires real SMTP -- not configured in CI. We instead verify the
260+
// Self-Service password-reset entry point is reachable, so a contractor
261+
// who did receive the email could complete the flow.
262+
await clearSession(page);
263+
await page.goto(`${BASE_URL}/#passwordReset/`);
264+
await page.waitForLoadState("networkidle");
265+
await expect(page.locator("body")).toContainText(/password/i, { timeout: 30000 });
266+
await assertNoErrors(page);
267+
});
268+
});
269+

0 commit comments

Comments
 (0)