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
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <p>
* 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)
* <p>
* 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 {

Expand All @@ -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 <button>. A plain :text-is() binds to the
// inner label <span> (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;
Expand All @@ -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() {
Expand All @@ -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.
* <p>
* 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@

/**
* Step 2 – authentication method picker.
* URL: https://openframe.build/auth/login/
* URL: https://openframe.build/auth/login
* <p>
* 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 {

Expand All @@ -17,43 +19,37 @@ 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
// <button>. The visible label sits in a <span> next to a provider icon,
// so a plain :text-is() would bind to the inner <span> 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;
}

// ── 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();
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -64,11 +65,13 @@ public class DevicesPage {

// ── Device row cards ─────────────────────────────────────────────────────
// Each row IS the detail link – an <a> that wraps the whole card:
// <a class="block rounded-md bg-ods-card ... cursor-pointer" href="/devices/details/{id}">
// <a class="block rounded-md bg-ods-card ... cursor-pointer" href="/devices/details?id={id}">
// (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: <span class="truncate"> (values: "ONLINE", "OFFLINE", "ARCHIVED")
private static final String ROW_STATUS = "span.truncate";
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading