diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthEntryPage.java b/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthEntryPage.java
index 8bb057021..84a1d0058 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthEntryPage.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthEntryPage.java
@@ -2,21 +2,27 @@
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
+import com.microsoft.playwright.options.WaitForSelectorState;
import com.microsoft.playwright.options.WaitUntilState;
+import java.util.regex.Pattern;
+
import static com.microsoft.playwright.options.LoadState.NETWORKIDLE;
import static com.openframe.test.config.EnvironmentConfig.getAuthUrl;
/**
* Step 1 – the public landing / auth entry page.
- * URL: https://openframe.build/auth/
+ * URL: https://openframe.build/auth
*
- * Contains two cards:
- * • "Create Organization" (out of scope)
- * • "Already Have an Account?" ← login entry point
+ * The left panel is a single card fronted by a Sign Up / Login segmented
+ * toggle:
+ * • "Sign Up" → "Create Organization" form (out of scope)
+ * • "Login" → "Login to OpenFrame" email form (login entry point)
*
- * The login card is uniquely identified by its outer wrapper:
- * div.bg-ods-bg.border.border-ods-border that contains h1 "Already Have an Account?"
+ * Selecting "Login" navigates to /auth/login and shows a single email field.
+ * Submitting the email reveals the auth-method picker inline (see
+ * {@link AuthMethodPage}) – the URL stays /auth/login, there is no further
+ * navigation.
*/
public class AuthEntryPage {
@@ -25,12 +31,22 @@ public class AuthEntryPage {
private final Page page;
// ── Selectors ─────────────────────────────────────────────────────────
- // Scoped to the login card to avoid collision with "Create Organization" card
- private static final String LOGIN_CARD = "div.bg-ods-bg.border.border-ods-border:has(h1:has-text('Already Have an Account'))";
- // private static final String EMAIL_INPUT = LOGIN_CARD + " input[type='email']";
-// private static final String CONTINUE_BTN = LOGIN_CARD + " button:has-text('Continue')";
- private static final String FORGOT_PWD = LOGIN_CARD + " button:has-text('Forgot password')";
- private static final String PAGE_HEADING = LOGIN_CARD + " h1";
+ // Buttons are matched by their rendered text via Playwright's :has-text(),
+ // which selects the ancestor . A plain :text-is() binds to the
+ // inner label (never the button), and these buttons expose no
+ // accessible name for getByRole() – so :has-text() is the reliable option.
+ private static final String SIGN_UP_TAB = "button:has-text('Sign Up')";
+ private static final String LOGIN_TAB = "button:has-text('Login')";
+ private static final String FORGOT_PWD = "button:has-text('Forgot Password?')";
+ // First auth-method button – used as the "email accepted" signal once the
+ // method picker is revealed after Continue.
+ private static final String SSO_BTN = "button:has-text('OpenFrame SSO')";
+ // Anchored so it does not also match "Continue with Google" / "…Microsoft"
+ // once the method picker is revealed.
+ private static final Pattern CONTINUE_TEXT = Pattern.compile("^\\s*Continue\\s*$");
+ // Login email form (shown once the Login tab is active)
+ private static final String PAGE_HEADING = "h1:has-text('Login to OpenFrame')";
+ private static final String EMAIL_INPUT = "input[type='email']";
public AuthEntryPage(Page page) {
this.page = page;
@@ -48,22 +64,35 @@ public AuthEntryPage navigate() {
// ── Locators ──────────────────────────────────────────────────────────
- public Locator loginCardHeading() {
+ public Locator signUpTab() {
+ return page.locator(SIGN_UP_TAB);
+ }
+
+ public Locator loginTab() {
+ return page.locator(LOGIN_TAB);
+ }
+
+ public Locator loginHeading() {
return page.locator(PAGE_HEADING);
}
public Locator emailInput() {
- return page.locator("input[type='email']").nth(1);
+ return page.locator(EMAIL_INPUT);
}
public Locator continueButton() {
- return page.locator("button:has-text('Continue')").nth(1);
+ return page.locator("button")
+ .filter(new Locator.FilterOptions().setHasText(CONTINUE_TEXT));
}
public Locator forgotPasswordLink() {
return page.locator(FORGOT_PWD);
}
+ private Locator ssoButton() {
+ return page.locator(SSO_BTN);
+ }
+
// ── Queries ───────────────────────────────────────────────────────────
public boolean isContinueEnabled() {
@@ -73,31 +102,71 @@ public boolean isContinueEnabled() {
// ── Actions ───────────────────────────────────────────────────────────
/**
- * Types the given email into the login card's email field.
- * The Continue button becomes enabled once a non-empty value is present.
+ * Selects the "Login" tab and waits for the login email form at
+ * /auth/login to render (both the email field and the Continue button
+ * present), so subsequent input is not raced against hydration.
+ */
+ public AuthEntryPage switchToLogin() {
+ loginTab().click();
+ page.waitForURL(
+ url -> url.contains("/auth/login"),
+ new Page.WaitForURLOptions().setTimeout(10_000)
+ );
+ emailInput().waitFor(new Locator.WaitForOptions()
+ .setState(WaitForSelectorState.VISIBLE)
+ .setTimeout(10_000));
+ continueButton().waitFor(new Locator.WaitForOptions()
+ .setState(WaitForSelectorState.VISIBLE)
+ .setTimeout(10_000));
+ return this;
+ }
+
+ /**
+ * Types the given email into the login email field and waits for the app
+ * to accept it – the Continue button only becomes enabled once a valid
+ * email is registered.
+ *
+ * The login form re-mounts when switching to the Login tab, and a value
+ * filled during that hydration window can be dropped by the controlled
+ * input (leaving Continue permanently disabled). We therefore re-fill
+ * until the button reports enabled, which is the app's own confirmation
+ * that the email was accepted.
*/
public AuthEntryPage enterEmail(String email) {
- emailInput().fill(email);
+ Locator input = emailInput();
+ input.fill(email);
+ page.waitForCondition(
+ () -> {
+ if (continueButton().isEnabled()) {
+ return true;
+ }
+ if (!email.equals(input.inputValue())) {
+ input.fill(email);
+ }
+ return false;
+ },
+ new Page.WaitForConditionOptions().setTimeout(15_000)
+ );
return this;
}
/**
- * Clicks Continue and waits for navigation to /auth/login/.
+ * Clicks Continue and waits for the auth-method picker to appear inline.
+ * No navigation occurs – the URL stays /auth/login.
* Returns the next-step page object.
*/
public AuthMethodPage clickContinue() {
continueButton().click();
- page.waitForURL(
- url -> url.contains("/auth/login"),
- new Page.WaitForURLOptions().setTimeout(10_000)
- );
+ ssoButton().waitFor(new Locator.WaitForOptions()
+ .setState(WaitForSelectorState.VISIBLE)
+ .setTimeout(10_000));
return new AuthMethodPage(page);
}
/**
- * Convenience: enters email and proceeds to step 2.
+ * Convenience: enters email and proceeds to the auth-method picker.
*/
public AuthMethodPage submitEmail(String email) {
return enterEmail(email).clickContinue();
}
-}
\ No newline at end of file
+}
diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthMethodPage.java b/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthMethodPage.java
index a3632e6fc..472f8bafd 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthMethodPage.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/pages/AuthMethodPage.java
@@ -5,10 +5,12 @@
/**
* Step 2 – authentication method picker.
- * URL: https://openframe.build/auth/login/
+ * URL: https://openframe.build/auth/login
*
- * Displayed after the email is submitted in Step 1.
- * Offers OpenFrame SSO, Microsoft, and Google as sign-in providers.
+ * Revealed inline (no navigation) after the email is submitted in Step 1 –
+ * the email field is replaced by the provider buttons on the same
+ * /auth/login page. Offers OpenFrame SSO, Google, and Microsoft as sign-in
+ * providers.
*/
public class AuthMethodPage {
@@ -17,11 +19,13 @@ public class AuthMethodPage {
private final Page page;
// ── Selectors ─────────────────────────────────────────────────────────
- private static final String HEADING = "h1:has-text('Already registered')";
- private static final String SSO_BTN = "button:has-text('Sign in with OpenFrame SSO')";
- private static final String MICROSOFT_BTN = "button:has-text('Continue with Microsoft')";
+ // Matched by rendered text via :has-text(), which selects the ancestor
+ // . The visible label sits in a next to a provider icon,
+ // so a plain :text-is() would bind to the inner and never match
+ // the button; these buttons also expose no accessible name for getByRole().
+ private static final String SSO_BTN = "button:has-text('OpenFrame SSO')";
private static final String GOOGLE_BTN = "button:has-text('Continue with Google')";
- private static final String BACK_BTN = "button:has-text('Back')";
+ private static final String MICROSOFT_BTN = "button:has-text('Continue with Microsoft')";
public AuthMethodPage(Page page) {
this.page = page;
@@ -29,31 +33,23 @@ public AuthMethodPage(Page page) {
// ── Locators ──────────────────────────────────────────────────────────
- public Locator heading() {
- return page.locator(HEADING);
- }
-
public Locator ssoButton() {
return page.locator(SSO_BTN);
}
- public Locator microsoftButton() {
- return page.locator(MICROSOFT_BTN);
- }
-
public Locator googleButton() {
return page.locator(GOOGLE_BTN);
}
- public Locator backButton() {
- return page.locator(BACK_BTN);
+ public Locator microsoftButton() {
+ return page.locator(MICROSOFT_BTN);
}
// ── Actions ───────────────────────────────────────────────────────────
/**
- * Clicks "Sign in with OpenFrame SSO" and waits for navigation to
- * the SSO credential form at /sas/login.
+ * Clicks "OpenFrame SSO" and waits for navigation to the SSO credential
+ * form at /sas/login.
*/
public SsoLoginPage clickSignInWithOpenFrameSso() {
ssoButton().click();
@@ -63,16 +59,4 @@ public SsoLoginPage clickSignInWithOpenFrameSso() {
);
return new SsoLoginPage(page);
}
-
- /**
- * Clicks Back and returns to the Auth Entry page.
- */
- public AuthEntryPage clickBack() {
- backButton().click();
- page.waitForURL(
- url -> url.endsWith("/auth"),
- new Page.WaitForURLOptions().setTimeout(10_000)
- );
- return new AuthEntryPage(page);
- }
-}
\ No newline at end of file
+}
diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/pages/DeviceDetailsPage.java b/openframe-test-service-core/src/main/java/com/openframe/test/pages/DeviceDetailsPage.java
index f1373e03e..6feb4652d 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/pages/DeviceDetailsPage.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/pages/DeviceDetailsPage.java
@@ -119,7 +119,9 @@ public void clickBackToDevices() {
* @return a new {@link RemoteDesktopPage} scoped to the same page
*/
public RemoteDesktopPage openRemoteDesktop() {
- page.locator("main a[href$='/remote-desktop']").first().click();
+ // href is query-param based (/devices/details/remote-desktop?id=…), so
+ // match on "contains" rather than "ends-with".
+ page.locator("main a[href*='/remote-desktop']").first().click();
page.waitForURL(
url -> url.contains("/remote-desktop"),
new Page.WaitForURLOptions().setTimeout(15_000));
diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/pages/DevicesPage.java b/openframe-test-service-core/src/main/java/com/openframe/test/pages/DevicesPage.java
index 25c5c1199..b37027d62 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/pages/DevicesPage.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/pages/DevicesPage.java
@@ -2,6 +2,7 @@
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
+import com.microsoft.playwright.TimeoutError;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitForSelectorState;
@@ -64,11 +65,13 @@ public class DevicesPage {
// ── Device row cards ─────────────────────────────────────────────────────
// Each row IS the detail link – an that wraps the whole card:
- //
+ //
// (device name span, status badge, last-seen, and the "More actions" button)
// Matched by its stable href rather than churn-prone Tailwind utility classes.
+ // Note: the detail URL is query-param based (/devices/details?id=…), so the
+ // match deliberately stops at "/devices/details" (no trailing slash).
private static final String DEVICE_ROW =
- "main a[href*='/devices/details/']";
+ "main a[href*='/devices/details']";
// Status badge: (values: "ONLINE", "OFFLINE", "ARCHIVED")
private static final String ROW_STATUS = "span.truncate";
@@ -509,10 +512,30 @@ public RemoteDesktopPage clickRemoteControlInMenu() {
* @return the resulting {@link DeviceDetailsPage}
*/
public DeviceDetailsPage openDevice(String deviceName) {
- deviceRowByName(deviceName).click();
- page.waitForURL(
- url -> url.contains("/devices/details"),
- new Page.WaitForURLOptions().setTimeout(10_000));
+ // The list can hold many devices (and only renders a subset at a time),
+ // so narrow it down via the search box before clicking the row.
+ searchInput().fill(deviceName);
+ // Let the filtered result set finish rendering before interacting, so
+ // the click lands on the settled row and not a node about to be
+ // replaced (which would swallow the SPA navigation).
+ page.waitForLoadState(LoadState.NETWORKIDLE);
+ Locator row = deviceRowByName(deviceName);
+ row.waitFor(new Locator.WaitForOptions()
+ .setState(WaitForSelectorState.VISIBLE)
+ .setTimeout(15_000));
+ row.click();
+ try {
+ page.waitForURL(
+ url -> url.contains("/devices/details"),
+ new Page.WaitForURLOptions().setTimeout(12_000));
+ } catch (TimeoutError firstClickMissed) {
+ // Occasionally the first click does not trigger SPA navigation
+ // (row re-rendered under the cursor); click the settled row again.
+ row.click();
+ page.waitForURL(
+ url -> url.contains("/devices/details"),
+ new Page.WaitForURLOptions().setTimeout(12_000));
+ }
DeviceDetailsPage deviceDetailsPage = new DeviceDetailsPage(page);
page.waitForCondition(deviceDetailsPage::isLoaded);
return deviceDetailsPage;
diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/pages/NavigationSidebar.java b/openframe-test-service-core/src/main/java/com/openframe/test/pages/NavigationSidebar.java
index 35fcb5855..b2182c43c 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/pages/NavigationSidebar.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/pages/NavigationSidebar.java
@@ -31,15 +31,17 @@ public class NavigationSidebar {
// ── Primary nav items (stable: aria-label never changes) ────────────────
private static final String NAV_DASHBOARD = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Dashboard']";
- private static final String NAV_ORGANIZATIONS = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Organizations']";
+ private static final String NAV_CUSTOMERS = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Customers']";
private static final String NAV_DEVICES = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Devices']";
private static final String NAV_SCRIPTS = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Scripts']";
private static final String NAV_MONITORING = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Monitoring']";
private static final String NAV_LOGS = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Logs']";
private static final String NAV_TICKETS = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Tickets']";
- private static final String NAV_MINGO = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Mingo']";
+ private static final String NAV_WORKTIME = SIDEBAR + " nav[aria-label='Primary navigation'] button[aria-label='Worktime']";
// ── Secondary nav ────────────────────────────────────────────────────────
+ private static final String NAV_KNOWLEDGE_BASE = SIDEBAR + " nav[aria-label='Secondary navigation'] button[aria-label='Knowledge Base']";
+ private static final String NAV_HELP_CENTER = SIDEBAR + " nav[aria-label='Secondary navigation'] button[aria-label='Help Center']";
private static final String NAV_SETTINGS = SIDEBAR + " nav[aria-label='Secondary navigation'] button[aria-label='Settings']";
// ── Collapse / expand ────────────────────────────────────────────────────
@@ -50,13 +52,15 @@ public class NavigationSidebar {
// ── Expected URL fragments for each nav item ─────────────────────────────
private static final String URL_DASHBOARD = "/dashboard";
- private static final String URL_ORGANIZATIONS = "/organizations";
+ private static final String URL_CUSTOMERS = "/customers";
private static final String URL_DEVICES = "/devices";
private static final String URL_SCRIPTS = "/scripts";
private static final String URL_MONITORING = "/monitoring";
private static final String URL_LOGS = "/logs";
private static final String URL_TICKETS = "/tickets";
- private static final String URL_MINGO = "/mingo";
+ private static final String URL_WORKTIME = "/worktime";
+ private static final String URL_KNOWLEDGE_BASE = "/knowledge-base";
+ private static final String URL_HELP_CENTER = "/help-center";
private static final String URL_SETTINGS = "/settings";
// ── Constructor ──────────────────────────────────────────────────────────
@@ -76,8 +80,8 @@ public Locator dashboardNavItem() {
return page.locator(NAV_DASHBOARD);
}
- public Locator organizationsNavItem() {
- return page.locator(NAV_ORGANIZATIONS);
+ public Locator customersNavItem() {
+ return page.locator(NAV_CUSTOMERS);
}
public Locator devicesNavItem() {
@@ -100,8 +104,16 @@ public Locator ticketsNavItem() {
return page.locator(NAV_TICKETS);
}
- public Locator mingoNavItem() {
- return page.locator(NAV_MINGO);
+ public Locator worktimeNavItem() {
+ return page.locator(NAV_WORKTIME);
+ }
+
+ public Locator knowledgeBaseNavItem() {
+ return page.locator(NAV_KNOWLEDGE_BASE);
+ }
+
+ public Locator helpCenterNavItem() {
+ return page.locator(NAV_HELP_CENTER);
}
public Locator settingsNavItem() {
@@ -168,10 +180,10 @@ public void goToDashboard() {
}
/**
- * Navigates to Organizations.
+ * Navigates to Customers.
*/
- public void goToOrganizations() {
- clickNavItem(organizationsNavItem(), URL_ORGANIZATIONS);
+ public void goToCustomers() {
+ clickNavItem(customersNavItem(), URL_CUSTOMERS);
}
/**
@@ -218,10 +230,24 @@ public void goToTickets() {
}
/**
- * Navigates to Mingo.
+ * Navigates to Worktime.
+ */
+ public void goToWorktime() {
+ clickNavItem(worktimeNavItem(), URL_WORKTIME);
+ }
+
+ /**
+ * Navigates to the Knowledge Base.
+ */
+ public void goToKnowledgeBase() {
+ clickNavItem(knowledgeBaseNavItem(), URL_KNOWLEDGE_BASE);
+ }
+
+ /**
+ * Navigates to the Help Center.
*/
- public void goToMingo() {
- clickNavItem(mingoNavItem(), URL_MINGO);
+ public void goToHelpCenter() {
+ clickNavItem(helpCenterNavItem(), URL_HELP_CENTER);
}
/**
diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/pages/SsoLoginPage.java b/openframe-test-service-core/src/main/java/com/openframe/test/pages/SsoLoginPage.java
index c32c0d420..041925f52 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/pages/SsoLoginPage.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/pages/SsoLoginPage.java
@@ -8,9 +8,11 @@
* URL: https://openframe.build/sas/login
*
* Server-rendered form (id="loginForm", method POST).
- * Fields use stable id attributes: #username and #password.
+ * Fields use stable id attributes: #username and #password; the submit
+ * control is #submitBtn (labelled "Continue").
*
- * Success: POST redirects to test-qa.openframe.build/dashboard/
+ * Success: POST redirects to the tenant host with a one-time ?devTicket=…
+ * query, which then lands on /dashboard.
* Failure: POST redirects back to GET /sas/login; fields retain values,
* no inline error message is shown.
*/
diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/pages/flow/UILoginFlow.java b/openframe-test-service-core/src/main/java/com/openframe/test/pages/flow/UILoginFlow.java
index 4d4345a78..17a676448 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/pages/flow/UILoginFlow.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/pages/flow/UILoginFlow.java
@@ -16,6 +16,7 @@ public UILoginFlow(Page page) {
public NavigationSidebar login(String email, String password) {
DashboardPage dashboardPage = new AuthEntryPage(this.page)
.navigate()
+ .switchToLogin()
.submitEmail(email)
.clickSignInWithOpenFrameSso()
.login(email, password);
diff --git a/openframe-test-service-core/src/main/java/com/openframe/test/tests/ui/BaseUITest.java b/openframe-test-service-core/src/main/java/com/openframe/test/tests/ui/BaseUITest.java
index a933a76f4..62dca6f29 100644
--- a/openframe-test-service-core/src/main/java/com/openframe/test/tests/ui/BaseUITest.java
+++ b/openframe-test-service-core/src/main/java/com/openframe/test/tests/ui/BaseUITest.java
@@ -46,7 +46,7 @@ public void handleBeforeEachMethodExecutionException(ExtensionContext ctx, Throw
static void launchBrowser() {
playwright = Playwright.create();
browser = playwright.chromium().launch(
- new BrowserType.LaunchOptions().setHeadless(true)
+ new BrowserType.LaunchOptions().setHeadless(false)
);
}