diff --git a/locators/nextcloud.json b/locators/nextcloud.json index d88a416..421a80e 100644 --- a/locators/nextcloud.json +++ b/locators/nextcloud.json @@ -17,6 +17,16 @@ "by": "locator", "value": ".welcome .close, .modal__close, [aria-label*='close'], [aria-label*='Close']" }, + "welcomeDialog": { + "by": "getByRole", + "value": { + "role": "dialog", + "name": "Welcome to Nextcloud!", + "options": { + "exact": false + } + } + }, "profileIcon": { "by": "locator", "value": "button[aria-label='Settings menu']" diff --git a/locators/openproject.json b/locators/openproject.json index 0e54671..7e8cbeb 100644 --- a/locators/openproject.json +++ b/locators/openproject.json @@ -227,6 +227,22 @@ "fileExistsReplaceButton": { "by": "locator", "value": ".spot-modal .spot-action-bar--right .button.-primary:has-text('Replace')" + }, + "languageSelectionModal": { + "by": "locator", + "value": "[role='dialog']:has-text('Please select your language')" + }, + "languageSelectionSaveButton": { + "by": "locator", + "value": "[role='dialog']:has-text('Please select your language') button:has-text('Save')" + }, + "tutorialOverlay": { + "by": "locator", + "value": "body:has-text('Take a three-minute introduction tour')" + }, + "tutorialSkipButton": { + "by": "locator", + "value": "body:has-text('Take a three-minute introduction tour') >> text=Skip" } } } diff --git a/pageobjects/nextcloud/NextcloudOpenIDConnectPage.ts b/pageobjects/nextcloud/NextcloudOpenIDConnectPage.ts index 1ec5a76..4c08ff2 100644 --- a/pageobjects/nextcloud/NextcloudOpenIDConnectPage.ts +++ b/pageobjects/nextcloud/NextcloudOpenIDConnectPage.ts @@ -1,5 +1,8 @@ import { Page } from '@playwright/test'; import { NextcloudBasePage } from './NextcloudBasePage'; +import { resolveHostname } from '../../utils/url-helpers'; +import { testConfig } from '../../utils/config'; +import { logDebug, logWarn } from '../../utils/logger'; export class NextcloudOpenIDConnectPage extends NextcloudBasePage { constructor(page: Page) { @@ -13,9 +16,41 @@ export class NextcloudOpenIDConnectPage extends NextcloudBasePage { async waitForReady(): Promise { await this.page.waitForURL(/.*\/settings\/admin\/user_oidc.*/, { timeout: 10000 }); + await this.dismissWelcomeModalIfPresent(); await this.getLocator('registeredProvidersText').waitFor({ state: 'visible', timeout: 10000 }); } + async dismissWelcomeModalIfPresent(): Promise { + const welcomeDialog = this.getLocator('welcomeDialog').first(); + const isDialogVisible = await welcomeDialog.isVisible({ timeout: 1500 }).catch(() => false); + if (!isDialogVisible) return; + + logDebug('[Nextcloud] Welcome modal detected, dismissing it'); + + try { + const closeButton = this.getLocator('welcomeMessageClose').first(); + const isCloseVisible = await closeButton.isVisible({ timeout: 2000 }).catch(() => false); + if (isCloseVisible) { + await closeButton.click(); + } else { + await this.page.keyboard.press('Escape').catch(() => undefined); + } + + await welcomeDialog.waitFor({ state: 'hidden', timeout: 10000 }); + } catch (error: unknown) { + logWarn('[Nextcloud] Failed to dismiss welcome modal', error); + } + } + + private resolveExpectedHostname(envUrl: string | undefined, envHost: string | undefined, fallbackHost: string): string { + return ( + resolveHostname(envUrl) || + resolveHostname(envHost) || + resolveHostname(fallbackHost) || + fallbackHost + ); + } + async verifyKeycloakProviderDetails(): Promise { try { const detailsSection = this.getLocator('keycloakProviderDetails'); @@ -41,7 +76,12 @@ export class NextcloudOpenIDConnectPage extends NextcloudBasePage { const nextSpan = el.nextElementSibling; return nextSpan?.tagName === 'SPAN' ? nextSpan.textContent : null; }); - if (!discoveryEndpoint?.includes('keycloak.test/realms/opnc/.well-known/openid-configuration')) { + const keycloakHost = this.resolveExpectedHostname( + process.env.KEYCLOAK_URL, + process.env.KEYCLOAK_HOST, + testConfig.keycloak.host, + ); + if (!discoveryEndpoint?.includes(`${keycloakHost}/realms/opnc/.well-known/openid-configuration`)) { return false; } @@ -50,7 +90,12 @@ export class NextcloudOpenIDConnectPage extends NextcloudBasePage { const nextSpan = el.nextElementSibling; return nextSpan?.tagName === 'SPAN' ? nextSpan.textContent : null; }); - if (!backchannelLogoutUrl?.includes('nextcloud.test/apps/user_oidc/backchannel-logout/keycloak')) { + const nextcloudHost = this.resolveExpectedHostname( + process.env.NEXTCLOUD_URL, + process.env.NEXTCLOUD_HOST, + testConfig.nextcloud.host, + ); + if (!backchannelLogoutUrl?.includes(`${nextcloudHost}/apps/user_oidc/backchannel-logout/keycloak`)) { return false; } @@ -59,7 +104,7 @@ export class NextcloudOpenIDConnectPage extends NextcloudBasePage { const nextSpan = el.nextElementSibling; return nextSpan?.tagName === 'SPAN' ? nextSpan.textContent : null; }); - if (!redirectUri?.includes('nextcloud.test/apps/user_oidc/code')) { + if (!redirectUri?.includes(`${nextcloudHost}/apps/user_oidc/code`)) { return false; } diff --git a/pageobjects/openproject/OpenProjectHomePage.ts b/pageobjects/openproject/OpenProjectHomePage.ts index 549a0ff..c85b327 100644 --- a/pageobjects/openproject/OpenProjectHomePage.ts +++ b/pageobjects/openproject/OpenProjectHomePage.ts @@ -1,17 +1,77 @@ import { Page } from '@playwright/test'; import { OpenProjectBasePage } from './OpenProjectBasePage'; +import { logDebug, logWarn } from '../../utils/logger'; export class OpenProjectHomePage extends OpenProjectBasePage { + private static readonly FIRST_LOGIN_PROMPT_PASSES = 3; + constructor(page: Page) { super(page); } async waitForReady(): Promise { - await this.page.waitForURL(/.*openproject\.test.*/, { timeout: 15000 }); + await this.waitForOpenProjectUrl(15000); + await this.dismissFirstLoginPromptsIfPresent(); const userProfileButton = this.getLocator('userProfileButton').first(); await userProfileButton.waitFor({ state: 'visible', timeout: 10000 }); } + private async dismissFirstLoginPromptsIfPresent(): Promise { + for (let attempt = 0; attempt < OpenProjectHomePage.FIRST_LOGIN_PROMPT_PASSES; attempt += 1) { + const dismissedLanguageModal = await this.dismissLanguageSelectionModalIfPresent(); + if (dismissedLanguageModal) continue; + + const dismissedTutorialOverlay = await this.dismissTutorialOverlayIfPresent(); + if (dismissedTutorialOverlay) continue; + + break; + } + } + + async dismissLanguageSelectionModalIfPresent(): Promise { + const modal = this.getLocator('languageSelectionModal').first(); + + const isVisible = await modal.isVisible({ timeout: 2000 }).catch(() => false); + if (!isVisible) return false; + + logDebug('[OpenProject] Language selection modal detected, saving default language'); + + try { + const saveButton = this.getLocator('languageSelectionSaveButton').first(); + await saveButton.waitFor({ state: 'visible', timeout: 5000 }); + await saveButton.click(); + await modal.waitFor({ state: 'hidden', timeout: 10000 }); + return true; + } catch (error: unknown) { + logWarn('[OpenProject] Failed to dismiss language selection modal', error); + return false; + } + } + + async dismissTutorialOverlayIfPresent(): Promise { + const hasTutorialText = await this.page + .getByText('Take a three-minute introduction tour', { exact: false }) + .isVisible({ timeout: 2000 }) + .catch(() => false); + + if (!hasTutorialText) return false; + + logDebug('[OpenProject] Tutorial overlay detected, skipping it'); + + try { + const skipButton = this.getLocator('tutorialSkipButton').first(); + await skipButton.waitFor({ state: 'visible', timeout: 5000 }); + await skipButton.click(); + await this.page + .getByText('Take a three-minute introduction tour', { exact: false }) + .waitFor({ state: 'hidden', timeout: 10000 }); + return true; + } catch (error: unknown) { + logWarn('[OpenProject] Failed to dismiss tutorial overlay', error); + return false; + } + } + async isLoggedIn(): Promise { try { const userProfileButton = this.getLocator('userProfileButton').first();