diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index e688efd22..f4527707c 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -91,7 +91,8 @@ jobs: # runs-on: [ self-hosted, macOS ] # test_script: browserstack-sdk pytest -s ./test/test_android.py --browserstack.config "browserstack.android.yml" concurrency: - group: test-${{ matrix.targetPlatform }} + group: ui-tests-email-inbox + cancel-in-progress: false # Let tests complete rather than canceling runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@v3 @@ -146,7 +147,7 @@ jobs: security list-keychains build-ios: #test-ios: name: Run iOS build #UI tests 🧪 - needs: + needs: - build - test runs-on: [ self-hosted, macOS ] @@ -170,4 +171,3 @@ jobs: # BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} # working-directory: sample/Tests/test/ios # run: browserstack-sdk pytest -xs ./test_ios.py --browserstack.config "browserstack.ios.yml" - diff --git a/sample/Tests/README.md b/sample/Tests/README.md new file mode 100644 index 000000000..b3dc7cd56 --- /dev/null +++ b/sample/Tests/README.md @@ -0,0 +1,37 @@ +# UI Tests + +## Prerequisites + +### Passport SDK Log Level Configuration + +For the authentication flow tests to work properly, the Passport SDK must be configured with an appropriate log level that enables auth URL capture. The test automation relies on capturing authentication URLs from Unity's Player.log. + +**Required Configuration:** + +In your Unity project's Passport initialisation script, ensure the log level is set to `Info` or `Debug`: + +**File:** `src/Packages/Passport/Runtime/Scripts/Passport/PassportInitialisation/PassportInitialisationScript.cs` + +```csharp +// Set the log level for the SDK (required for test automation) +Passport.LogLevel = LogLevel.Info; // or LogLevel.Debug +``` + +**Why This Is Required:** + +- The test framework captures authentication URLs from Unity logs using `PassportLogger.Info()` calls +- Without proper logging, authentication URL interception will fail +- This enables the workaround for browser process isolation issues in automated testing environments + +**Log Patterns Captured:** + +The tests monitor Unity's `Player.log` for these patterns: + +- `[Immutable] PASSPORT_AUTH_URL: ` +- `[Immutable] [Browser Communications Manager] LaunchAuthURL : ` + +If authentication tests fail to capture URLs, verify that: + +1. The Passport SDK log level is set correctly +2. Unity's Player.log is being written to the expected location +3. The authentication flow is actually being triggered diff --git a/sample/Tests/src/fetch_otp.py b/sample/Tests/src/fetch_otp.py index ca3622af9..117937d65 100644 --- a/sample/Tests/src/fetch_otp.py +++ b/sample/Tests/src/fetch_otp.py @@ -9,6 +9,8 @@ def get_mailslurp_client(): configuration = mailslurp_client.Configuration() configuration.api_key['x-api-key'] = os.getenv('MAILSLURP_API_KEY') + # Use the correct API base URL as per official docs + configuration.host = "https://api.mailslurp.com" api_client = mailslurp_client.ApiClient(configuration) waitfor_controller = WaitForControllerApi(api_client) return waitfor_controller @@ -23,7 +25,7 @@ def extract_otp_from_email(email_body): def fetch_code(): waitfor_controller = get_mailslurp_client() - email = waitfor_controller.wait_for_latest_email(inbox_id=INBOX_ID, timeout=30000, unread_only=True) + email = waitfor_controller.wait_for_latest_email(inbox_id=INBOX_ID, timeout=60000, unread_only=True) if email: otp = extract_otp_from_email(email.body) return otp diff --git a/sample/Tests/test/test_windows.py b/sample/Tests/test/test_windows.py index 583aa5b5a..b956d6d5d 100644 --- a/sample/Tests/test/test_windows.py +++ b/sample/Tests/test/test_windows.py @@ -1,17 +1,28 @@ +""" +Unity Passport Windows UI Tests + +For test setup and configuration requirements (especially Passport SDK log level), +see: sample/Tests/README.md + +These tests require proper authentication URL logging to work correctly. +""" + import time from alttester import * from test import TestConfig, UnityTest -from test_windows_helpers import login, open_sample_app, launch_browser, bring_sample_app_to_foreground, stop_browser, stop_sample_app +from test_windows_helpers import login, open_sample_app, launch_browser, bring_sample_app_to_foreground, stop_browser, stop_sample_app, logout_with_controlled_browser class WindowsTest(UnityTest): @classmethod def setUpClass(cls): - open_sample_app() + # Clear cached login state at the start of the test suite + open_sample_app(clear_data=True) time.sleep(5) # Give time for the app to open - super().setUpClass() + # Initialize AltDriver with longer timeout for flaky CI environment + cls.altdriver = AltDriver(timeout=120) # 120 seconds instead of default 20 @classmethod def tearDownClass(cls): @@ -21,83 +32,191 @@ def tearDownClass(cls): def restart_app_and_altdriver(self): self.stop_altdriver() stop_sample_app() - open_sample_app() + open_sample_app() # Normal restart without clearing data time.sleep(5) # Give time for the app to open - self.start_altdriver() + # Use same timeout as setUpClass + self.__class__.altdriver = AltDriver(timeout=120) def login(self): - # Wait for unauthenticated screen - self.get_altdriver().wait_for_current_scene_to_be("UnauthenticatedScene") - - for attempt in range(2): + """ + Smart login method that handles different app states: + - UnauthenticatedScene: Proceed with normal login + - AuthenticatedScene: Logout first, then login + - Other scenes: Wait for proper state + """ + print("=== SMART LOGIN: Checking app state ===") + + # Check what scene we're starting in + try: + current_scene = self.get_altdriver().get_current_scene() + print(f"Current scene: {current_scene}") + except Exception as e: + print(f"Could not get current scene: {e}") + raise SystemExit("Failed to determine app state") + + # Handle different starting states + if current_scene == "UnauthenticatedScene": + print("[OK] App is already unauthenticated - proceeding with login") + self._perform_login() + + elif current_scene == "AuthenticatedScene": + print("[WARNING] App is already authenticated - need to logout first") + self._logout_and_login() + + else: + print(f"[ERROR] Unexpected scene: {current_scene}") + # Try to wait for a known state + print("Waiting for app to reach a known state...") + for wait_attempt in range(3): + try: + current_scene = self.get_altdriver().get_current_scene() + if current_scene in ["UnauthenticatedScene", "AuthenticatedScene"]: + print(f"App reached known state: {current_scene}") + return self.login() # Recursive call with known state + time.sleep(5) + except Exception as e: + print(f"Wait attempt {wait_attempt + 1} failed: {e}") + + raise SystemExit(f"App stuck in unknown scene: {current_scene}") + + def _perform_login(self): + """Perform normal login flow when app is in UnauthenticatedScene""" + try: + # Debug: Check what scene we're actually in and what objects exist try: - # Check app state - login_button = self.get_altdriver().find_object(By.NAME, "LoginBtn") - print("Found login button, app is in the correct state") - - # Login - print("Logging in...") + current_scene = self.get_altdriver().get_current_scene() + print(f"DEBUG: _perform_login - current scene: {current_scene}") + except Exception as e: + print(f"DEBUG: Could not get current scene: {e}") + + # Wait a moment for UI to stabilize and check if app is still running + time.sleep(3) + + # Debug: Check if we can still communicate with the app + try: + connection_test = self.get_altdriver().get_current_scene() + print(f"DEBUG: App still responsive, scene: {connection_test}") + except Exception as e: + print(f"DEBUG: App may have crashed or lost connection: {e}") + raise SystemExit("App connection lost during login attempt") + + # Debug: Try to find any buttons to see what's available + try: + all_objects = self.get_altdriver().get_all_elements() + button_objects = [obj for obj in all_objects if 'btn' in obj.name.lower() or 'button' in obj.name.lower()] + print(f"DEBUG: Found button-like objects: {[obj.name for obj in button_objects]}") + except Exception as e: + print(f"DEBUG: Could not get all objects: {e}") + + # Check for login button + login_button = self.get_altdriver().find_object(By.NAME, "LoginBtn") + print("Found login button - performing login") + + # Login + launch_browser() + bring_sample_app_to_foreground() + login_button.tap() + login() + bring_sample_app_to_foreground() + + # Wait for authenticated screen + self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene") + stop_browser() + print("[SUCCESS] Login successful") + + except Exception as err: + stop_browser() + raise SystemExit(f"Login failed: {err}") + + def _logout_and_login(self): + """Handle logout and then login when app starts authenticated""" + print("Attempting logout to reset to unauthenticated state...") + + try: + # Use our improved logout method + print("Using controlled browser logout...") + logout_with_controlled_browser() + + # Wait for unauthenticated state + print("Waiting for UnauthenticatedScene after logout...") + self.get_altdriver().wait_for_current_scene_to_be("UnauthenticatedScene", timeout=30) + print("[SUCCESS] Successfully logged out") + + # Now perform normal login + self._perform_login() + + except Exception as logout_err: + print(f"Controlled logout failed: {logout_err}") + print("Trying fallback logout method...") + + try: + # Fallback: Direct logout button approach launch_browser() bring_sample_app_to_foreground() - login_button.tap() - login() + logout_button = self.get_altdriver().find_object(By.NAME, "LogoutBtn") + logout_button.tap() + time.sleep(10) # Give more time for logout bring_sample_app_to_foreground() - - # Wait for authenticated screen - self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene") + + # Wait for unauthenticated screen + self.get_altdriver().wait_for_current_scene_to_be("UnauthenticatedScene", timeout=30) stop_browser() - print("Logged in") - return - except Exception as err: + print("[SUCCESS] Fallback logout successful") + + # Now perform normal login + self._perform_login() + + except Exception as fallback_err: stop_browser() - - if attempt == 0: - # Reset app - - # Relogin (optional: only if the button is present) - print("Try reset the app and log out once...") - try: - self.get_altdriver().wait_for_object(By.NAME, "ReloginBtn").tap() - except Exception as e: - print("ReloginBtn not found, skipping relogin step. User may already be in AuthenticatedScene.") - - # Wait for authenticated screen - self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene") - print("Re-logged in") - - # Logout - print("Logging out...") - launch_browser() - bring_sample_app_to_foreground() - self.get_altdriver().find_object(By.NAME, "LogoutBtn").tap() - time.sleep(5) - bring_sample_app_to_foreground() - - # Wait for unauthenticated screen - self.get_altdriver().wait_for_current_scene_to_be("UnauthenticatedScene") - stop_browser() - print("Logged out and successfully reset app") - - time.sleep(5) - else: - raise SystemExit(f"Failed to reset app {err}") + print(f"[ERROR] Both logout methods failed:") + print(f" - Controlled logout: {logout_err}") + print(f" - Fallback logout: {fallback_err}") + raise SystemExit("Could not logout to reset app state") def test_1_login(self): + print("=" * 60) + print("STARTING TEST: test_1_login") + print("=" * 60) self.login() + print("COMPLETED TEST: test_1_login") + print("=" * 60) def test_2_other_functions(self): + print("=" * 60) + print("STARTING TEST: test_2_other_functions") + print("=" * 60) self.test_0_other_functions() + print("COMPLETED TEST: test_2_other_functions") + print("=" * 60) def test_3_passport_functions(self): + print("=" * 60) + print("STARTING TEST: test_3_passport_functions") + print("=" * 60) self.test_1_passport_functions() + print("COMPLETED TEST: test_3_passport_functions") + print("=" * 60) def test_4_imx_functions(self): + print("=" * 60) + print("STARTING TEST: test_4_imx_functions") + print("=" * 60) self.test_2_imx_functions() + print("COMPLETED TEST: test_4_imx_functions") + print("=" * 60) def test_5_zkevm_functions(self): + print("=" * 60) + print("STARTING TEST: test_5_zkevm_functions") + print("=" * 60) self.test_3_zkevm_functions() + print("COMPLETED TEST: test_5_zkevm_functions") + print("=" * 60) def test_6_relogin(self): + print("=" * 60) + print("STARTING TEST: test_6_relogin") + print("=" * 60) self.restart_app_and_altdriver() # Relogin @@ -117,8 +236,14 @@ def test_6_relogin(self): self.get_altdriver().find_object(By.NAME, "ConnectBtn").tap() time.sleep(5) self.assertEqual("Connected to IMX", output.get_text()) + + print("COMPLETED TEST: test_6_relogin") + print("=" * 60) def test_7_reconnect_connect_imx(self): + print("=" * 60) + print("STARTING TEST: test_7_reconnect_connect_imx") + print("=" * 60) self.restart_app_and_altdriver() # Reconnect @@ -143,14 +268,33 @@ def test_7_reconnect_connect_imx(self): launch_browser() bring_sample_app_to_foreground() self.get_altdriver().find_object(By.NAME, "LogoutBtn").tap() + + # Use controlled browser logout instead of waiting for scene change + logout_with_controlled_browser() + + # Give Unity time to process the logout callback time.sleep(5) bring_sample_app_to_foreground() # Wait for authenticated screen self.get_altdriver().wait_for_current_scene_to_be("UnauthenticatedScene") + stop_browser() print("Logged out") + print("COMPLETED TEST: test_7_reconnect_connect_imx") + print("=" * 60) + + def test_8_connect_imx(self): + print("=" * 60) + print("STARTING TEST: test_8_connect_imx") + print("=" * 60) + # Ensure clean state regardless of previous tests + self.restart_app_and_altdriver() + + # Wait for initial scene + self.get_altdriver().wait_for_current_scene_to_be("UnauthenticatedScene") + # Connect IMX print("Logging in and connecting to IMX...") launch_browser() @@ -178,6 +322,7 @@ def test_7_reconnect_connect_imx(self): bring_sample_app_to_foreground() print("Logging out...") self.get_altdriver().find_object(By.NAME, "LogoutBtn").tap() + logout_with_controlled_browser() time.sleep(5) bring_sample_app_to_foreground() @@ -185,3 +330,5 @@ def test_7_reconnect_connect_imx(self): self.get_altdriver().wait_for_current_scene_to_be("UnauthenticatedScene") stop_browser() print("Logged out") + print("COMPLETED TEST: test_8_connect_imx") + print("=" * 60) diff --git a/sample/Tests/test/test_windows_helpers.py b/sample/Tests/test/test_windows_helpers.py index 48ef6f619..dbf928915 100644 --- a/sample/Tests/test/test_windows_helpers.py +++ b/sample/Tests/test/test_windows_helpers.py @@ -1,3 +1,23 @@ +""" +Windows Test Helpers for Unity Passport Authentication + +CRITICAL WORKAROUND IMPLEMENTED: +This module implements browser process isolation workarounds for Unity Passport authentication. + +PROBLEM: +Unity's Application.OpenURL() opens authentication URLs in separate browser processes that +automated testing tools (Selenium) cannot control. This breaks authentication flows in CI. + +SOLUTION: +1. Launch browser with remote debugging enabled (port 9222) +2. Monitor Unity logs to capture auth/logout URLs that Unity wants to open +3. Navigate controlled browser to captured URLs instead of relying on Unity's browser +4. Complete authentication/logout in controlled browser +5. Unity receives callbacks properly due to protocol association setup + +This approach enables reliable automated testing of Passport authentication flows. +""" + import os import re import subprocess @@ -39,56 +59,366 @@ def get_product_name(): # If regex fails, return default return "SampleApp" +def get_auth_url_from_unity_logs(): + """Monitor Unity logs to capture the PASSPORT_AUTH_URL.""" + import tempfile + import os + + # Unity log file locations on Windows + log_paths = [ + os.path.join(os.path.expanduser("~"), "AppData", "LocalLow", "Immutable", "Immutable Sample", "Player.log"), + os.path.join(tempfile.gettempdir(), "UnityPlayer.log"), + "Player.log" # Current directory + ] + + for log_path in log_paths: + if os.path.exists(log_path): + print(f"Monitoring Unity log: {log_path}") + # Read the log file and look for PASSPORT_AUTH_URL + try: + with open(log_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + # Look for either our custom message or the existing LaunchAuthURL message + # Get the LAST occurrence (most recent) and make sure it's a login URL, not logout + # Now includes [Immutable] tag from PassportLogger + matches = re.findall(r'(?:\[Immutable\] PASSPORT_AUTH_URL: |PASSPORT_AUTH_URL: |LaunchAuthURL : )(https?://[^\s]+)', content) + if matches: + # Get the last URL and make sure it's not a logout URL + for url in reversed(matches): + if 'im-logged-out' not in url and 'logout' not in url: + match = type('obj', (object,), {'group': lambda x, n: url if n == 1 else None})() + break + else: + # All URLs were logout URLs, take the last one anyway + if matches: + match = type('obj', (object,), {'group': lambda x, n: matches[-1] if n == 1 else None})() + else: + match = None + else: + match = None + if match: + url = match.group(1) + print(f"Found auth URL in Unity logs: {url}") + return url + except Exception as e: + print(f"Error reading log file {log_path}: {e}") + continue + + print("No auth URL found in Unity logs") + return None + +def get_logout_url_from_unity_logs(): + """Monitor Unity logs to capture logout URLs.""" + import tempfile + import os + + # Unity log file locations on Windows + log_paths = [ + os.path.join(os.path.expanduser("~"), "AppData", "LocalLow", "Immutable", "Immutable Sample", "Player.log"), + os.path.join(tempfile.gettempdir(), "UnityPlayer.log"), + "Player.log" # Current directory + ] + + for log_path in log_paths: + if os.path.exists(log_path): + print(f"Monitoring Unity log for logout URL: {log_path}") + try: + with open(log_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + # Look for logout URLs in Unity logs (uses same PASSPORT_AUTH_URL pattern) + # Now includes [Immutable] tag from PassportLogger + matches = re.findall(r'(?:\[Immutable\] PASSPORT_AUTH_URL: |PASSPORT_AUTH_URL: |LaunchAuthURL : )(https?://[^\s]+)', content) + if matches: + # Get the last URL and make sure it's a logout URL + for url in reversed(matches): + if 'logout' in url or 'im-logged-out' in url: + print(f"Found logout URL: {url}") + return url + except Exception as e: + print(f"Error reading log file {log_path}: {e}") + continue + + print("No logout URL found in Unity logs") + return None + +def logout_with_controlled_browser(): + """Handle logout using the controlled browser instance instead of letting Unity open its own browser.""" + print("Starting controlled logout process...") + + # Set up Chrome WebDriver options to connect to the existing browser instance + chrome_options = Options() + chrome_options.add_experimental_option("debuggerAddress", "localhost:9222") + + try: + # Connect to the existing browser instance + driver = webdriver.Chrome(options=chrome_options) + print("Connected to existing browser for logout") + + # Monitor Unity logs for logout URL + print("Monitoring Unity logs for logout URL...") + logout_url = None + for attempt in range(15): # Try for 15 seconds (shorter timeout) + logout_url = get_logout_url_from_unity_logs() + if logout_url: + break + time.sleep(1) + + if logout_url: + print(f"Navigating controlled browser to logout URL: {logout_url}") + driver.get(logout_url) + + # Wait for logout to complete (protocol is already configured, no dialogs expected) + time.sleep(3) + print("Logout completed in controlled browser") + + # Check final page + current_url = driver.current_url + print(f"Final logout URL: {current_url}") + + else: + print("Could not find logout URL in Unity logs - logout may complete without browser interaction") + + except Exception as e: + print(f"Error during controlled logout: {e}") + print("Logout may need to be handled by Unity directly") + + print("Controlled logout process completed") + +def handle_cached_authentication(driver): + """Handle scenarios where user is already authenticated (cached session)""" + print("Handling cached authentication scenario...") + print(f"Current URL: {driver.current_url}") + print(f"Page title: {driver.title}") + + # Give a moment for any page transitions to complete + time.sleep(3) + + # Handle deep link processing based on environment + is_ci = os.getenv('CI') or os.getenv('GITHUB_ACTIONS') or os.getenv('BUILD_ID') + + if is_ci: + print("CI environment - checking if authentication completed automatically") + print("Monitoring Unity logs for authentication completion...") + + auth_success = False + for check_attempt in range(30): # Check for 30 seconds + try: + with open("C:\\Users\\WindowsBuildsdkServi\\AppData\\LocalLow\\Immutable\\Immutable Sample\\Player.log", 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + # Look for signs of successful authentication + if any(phrase in content for phrase in [ + "AuthenticatedScene", + "authentication successful", + "logged in successfully", + "Passport token received" + ]): + print("Authentication success detected in Unity logs!") + auth_success = True + break + except: + pass + time.sleep(1) + + if not auth_success: + print("No authentication success detected - attempting automated dialog handling") + print("Looking for protocol permission dialog to click automatically...") + + # Try to find and click the protocol dialog automatically + try: + ps_script = ''' + for ($i = 0; $i -lt 10; $i++) { + $windows = Get-Process | Where-Object { $_.MainWindowTitle -like "*auth.immutable.com*" -or $_.MainWindowTitle -like "*Open*" } + foreach ($window in $windows) { + try { + Add-Type -AssemblyName UIAutomationClient + $element = [Windows.Automation.AutomationElement]::FromHandle($window.MainWindowHandle) + if ($element) { + $buttons = $element.FindAll([Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.Condition]::new([Windows.Automation.AutomationElement]::ControlTypeProperty, + [Windows.Automation.ControlType]::Button)) + foreach ($button in $buttons) { + $buttonText = $button.Current.Name + if ($buttonText -like "*Open*" -or $buttonText -like "*Allow*" -or $buttonText -like "*Yes*") { + $button.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke() + Write-Host "Clicked protocol dialog button: $buttonText" + exit 0 + } + } + } + } catch {} + } + Start-Sleep 1 + } + Write-Host "No protocol dialog found" + ''' + + result = subprocess.run(["powershell", "-Command", ps_script], + capture_output=True, text=True, timeout=15) + if "Clicked protocol dialog" in result.stdout: + print("Successfully automated protocol dialog click in CI!") + # Wait a bit more for Unity to process + time.sleep(5) + else: + print("Could not find protocol dialog to automate") + except Exception as e: + print(f"CI dialog automation error: {e}") + print("Protocol dialog may require manual setup in CI environment") + + else: + print("Local environment - cached authentication should work automatically") + print("Waiting for Unity to receive the deep link callback...") + time.sleep(5) + print("Cached authentication processing complete") + + return # Exit since cached auth is complete + def login(): - print("Connect to Chrome") - # Set up Chrome options to connect to the existing Chrome instance + print("Connect to Brave via Chrome WebDriver") + # Set up Chrome WebDriver options to connect to the existing Brave instance + # (Brave uses Chromium engine so Chrome WebDriver works) chrome_options = Options() chrome_options.add_experimental_option("debuggerAddress", "localhost:9222") - # Connect to the existing Chrome instance + # Connect to the existing Brave browser instance driver = webdriver.Chrome(options=chrome_options) - print("Open a window on Chrome") - # Get the original window handle - original_window = driver.current_window_handle + # HYBRID APPROACH: Try multi-window detection first (proven to work in CI), + # then fall back to Unity log monitoring if needed + + print("Attempting multi-window detection (primary method - proven to work)...") + try: + # Wait for Unity to open auth URL in new browser window + print("Waiting for new window...") + WebDriverWait(driver, 15).until(EC.number_of_windows_to_be(2)) + + # Get all window handles + all_windows = driver.window_handles + print(f"Found {len(all_windows)} windows to check: {all_windows}") + + # Find the window with email input + target_window = None + for window in all_windows: + try: + print(f"Checking window: {window}") + driver.switch_to.window(window) + # Try to find email input in this window + email_field = driver.find_element(By.CSS_SELECTOR, '[data-testid="TextInput__input"]') + target_window = window + print(f"Found email input in window: {window}") + break + except: + print(f"Email input not found in window: {window}, trying next...") + continue - print("Waiting for new window...") - WebDriverWait(driver, 60).until(EC.number_of_windows_to_be(2)) + if target_window: + print("Switch to the target window") + driver.switch_to.window(target_window) + print("Multi-window detection successful - proceeding with login flow") + else: + raise Exception("No window with email input found") + + except Exception as e: + print(f"Multi-window detection failed: {e}") + print("Falling back to Unity log monitoring method...") + + # FALLBACK: Unity log monitoring approach + print("Looking for auth URL in Unity logs...") + auth_url = None + for attempt in range(30): # Try for 30 seconds + auth_url = get_auth_url_from_unity_logs() + if auth_url: + break + time.sleep(1) + + if auth_url: + print(f"Navigating to captured auth URL: {auth_url}") + driver.get(auth_url) + + # Debug: Check what page we landed on + time.sleep(5) # Give more time for potential redirects + print(f"After navigation - URL: {driver.current_url}") + print(f"After navigation - Title: {driver.title}") + + # Check if we have email field (login page) or if we skipped to redirect + try: + email_field = driver.find_element(By.CSS_SELECTOR, '[data-testid="TextInput__input"]') + print("Found email field via Unity log method - proceeding with login flow") + except: + print("No email field found - checking if we got redirected to new tab...") + + # If we ended up on chrome://newtab/ or similar, the redirect already happened + if 'newtab' in driver.current_url or 'about:blank' in driver.current_url: + print("Browser was redirected to new tab - cached session completed redirect automatically!") + print("The immutablerunner:// callback was triggered but browser couldn't handle it") + print("This means authentication was successful, just need to wait for Unity to process it") + + # Wait and check Unity logs for authentication success instead of relying on scene changes + auth_success = False + for check_attempt in range(20): # Check for 20 seconds + try: + with open("C:\\Users\\WindowsBuildsdkServi\\AppData\\LocalLow\\Immutable\\Immutable Sample\\Player.log", 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + # Look for signs of successful authentication in logs + if any(phrase in content for phrase in [ + "AuthenticatedScene", + "COMPLETE_LOGIN_PKCE", + "LoginPKCESuccess", + "HandleLoginPkceSuccess", + "authentication successful", + "logged in successfully" + ]): + print("Authentication success detected in Unity logs!") + auth_success = True + break + except: + pass + time.sleep(1) + + if auth_success: + print("Cached authentication confirmed successful via Unity logs") + else: + print("Could not confirm authentication success in Unity logs") + + return + else: + print("Unexpected page state - handling as cached session...") + return handle_cached_authentication(driver) + else: + print("Could not find auth URL in Unity logs either!") + driver.quit() + return - # Get all window handles - all_windows = driver.window_handles + wait = WebDriverWait(driver, 60) + + print("Wait for email input...") + email_field = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '[data-testid="TextInput__input"]'))) + print("Enter email...") + email_field.send_keys(EMAIL) - print(f"Found {len(all_windows)} new windows to check: {all_windows}") + # Try to find and click the submit button (arrow button) + submit_selectors = [ + 'button[type="submit"]', # Primary - always works + 'button[data-testid*="submit"]', # Fallback with testid + 'form button' # Last resort - any form button + ] - # Find the window with email input - target_window = None - for window in all_windows: + button_clicked = False + for selector in submit_selectors: try: - print(f"Checking window: {window}") - driver.switch_to.window(window) - driver.find_element(By.ID, ':r1:') - target_window = window - print(f"Found email input in window: {window}") + submit_button = WebDriverWait(driver, 5).until(EC.element_to_be_clickable((By.CSS_SELECTOR, selector))) + submit_button.click() + print(f"Successfully clicked submit button with selector: {selector}") + button_clicked = True break - except: - print(f"Email input not found in window: {window}, trying next...") + except Exception as e: + print(f"Submit button selector {selector} failed: {e}") continue - if not target_window: - print("Could not find email input field in any window!") - driver.quit() - return + if not button_clicked: + print("No submit button found with any selector, trying Enter key...") + email_field.send_keys(Keys.RETURN) + print("Pressed Enter key") - print("Switch to the target window") - driver.switch_to.window(target_window) - - wait = WebDriverWait(driver, 60) - - print("Wait for email input...") - email_field = wait.until(EC.presence_of_element_located((By.ID, ':r1:'))) - print("Enter email...") - email_field.send_keys(EMAIL) - email_field.send_keys(Keys.RETURN) - print("Entered email") + print("Email submission attempted") # Wait for the OTP to arrive and page to load print("Wait for OTP...") @@ -99,11 +429,56 @@ def login(): if code: print(f"Successfully fetched OTP: {code}") else: - print("Failed to fetch OTP from MailSlurp") - driver.quit() + print("Failed to fetch OTP from MailSlurp - checking if authentication completed anyway...") + + # Sometimes Auth0 doesn't send OTP emails in test environments + # Check if we can proceed anyway or if this is a cached session scenario + try: + # Check if we're already past the OTP stage + current_url = driver.current_url + print(f"Current URL after OTP timeout: {current_url}") + + # If we're at a success/callback page, authentication may have completed + if any(keyword in current_url.lower() for keyword in ['success', 'callback', 'complete', 'checking']): + print("Already at success page - proceeding without OTP") + print("Waiting for Unity to receive the callback...") + time.sleep(10) + return + + # Otherwise this is a real OTP failure + print("No OTP received and not at success page - authentication failed") + driver.quit() + return + except Exception as e: + print(f"Error checking page state after OTP timeout: {e}") + driver.quit() + return print("Find OTP input...") - otp_field = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'input[data-testid="passwordless_passcode__TextInput--0__input"]'))) + print(f"Current URL after email submission: {driver.current_url}") + print(f"Page title after email submission: {driver.title}") + + # Try multiple selectors for OTP input field + otp_selectors = [ + 'input[data-testid="passwordless_passcode__TextInput--0__input"]', # Primary - always works + 'input[data-testid*="passcode"]', # Fallback - partial testid match + 'input[type="text"][maxlength="6"]' # Last resort - by input characteristics + ] + + otp_field = None + for selector in otp_selectors: + try: + otp_field = WebDriverWait(driver, 5).until(EC.presence_of_element_located((By.CSS_SELECTOR, selector))) + print(f"Found OTP field with selector: {selector}") + break + except: + continue + + if not otp_field: + print("Could not find OTP input field with any selector!") + print("Page source snippet:") + print(driver.page_source[:2000]) # First 2000 chars for debugging + raise Exception("OTP input field not found") print("Enter OTP") otp_field.send_keys(code) @@ -111,12 +486,117 @@ def login(): wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'h1[data-testid="checking_title"]'))) print("Connected to Passport!") - driver.quit() + # Handle optional consent screen (shouldn't appear normally but sometimes does) + try: + print("Checking for consent screen...") + consent_yes_button = WebDriverWait(driver, 5).until( + EC.element_to_be_clickable((By.XPATH, "//button[text()='Yes' or contains(text(), 'Yes')]")) + ) + consent_yes_button.click() + print("Clicked 'Yes' on consent screen") + except: + print("No consent screen found (expected behavior)") + + # Handle deep link permission dialog + print("Waiting for deep link permission dialog...") + print(f"Current URL: {driver.current_url}") + print(f"Page title: {driver.title}") + + # Give a moment for any page transitions to complete + time.sleep(3) + + try: + # Check what's actually on the page + buttons = driver.find_elements(By.TAG_NAME, "button") + print(f"Found {len(buttons)} buttons on page:") + for i, btn in enumerate(buttons[:5]): # Show first 5 buttons + try: + text = btn.text.strip() + if text: + print(f" Button {i}: '{text}'") + except: + pass + + # Wait for the deep link dialog to appear and click "Open Immutable Sample.cmd" + # Use more specific selector to avoid clicking "Restore" button + deep_link_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[text()='Open Immutable Sample.cmd']"))) + deep_link_button.click() + print("Clicked deep link permission dialog - Unity should receive redirect") + except Exception as e: + print(f"Deep link dialog not found or failed to click: {e}") + print("This may cause the test to timeout waiting for scene change") + + # Keep browser alive for Unity deep link redirect + # driver.quit() + +def clear_unity_data(): + """Clear Unity's persistent data to force fresh start""" + print("Clearing Unity persistent data...") + + # Clear PlayerPrefs from Windows Registry + try: + import winreg + registry_path = r"SOFTWARE\Immutable\Immutable Sample" + + # Try both HKEY_CURRENT_USER and HKEY_LOCAL_MACHINE + for root_key in [winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE]: + try: + winreg.DeleteKey(root_key, registry_path) + print(f"Cleared PlayerPrefs from registry: {root_key}") + except FileNotFoundError: + pass # Key doesn't exist, that's fine + except Exception as e: + print(f"Could not clear registry {root_key}: {e}") + + except ImportError: + print("Windows registry module not available") + except Exception as e: + print(f"Error clearing registry: {e}") + + # Clear Application.persistentDataPath + try: + data_path = os.path.join(os.path.expanduser("~"), "AppData", "LocalLow", "Immutable", "Immutable Sample") + if os.path.exists(data_path): + import shutil + shutil.rmtree(data_path) + print(f"Cleared persistent data folder: {data_path}") + else: + print(f"No persistent data folder found at: {data_path}") + except Exception as e: + print(f"Error clearing persistent data: {e}") + + print("Unity data cleanup complete") -def open_sample_app(): +def open_sample_app(clear_data=False): product_name = get_product_name() + + # Clear any cached login state before opening (only when requested) + if clear_data: + clear_unity_data() + print(f"Opening {product_name}...") - subprocess.Popen([f"{product_name}.exe"], shell=True) + + # Look for the executable in build folder first, then current directory + exe_paths = [ + f"../build/{product_name}.exe", # Relative to Tests folder + f"{product_name}.exe" # Current directory (fallback) + ] + + exe_launched = False + for exe_path in exe_paths: + if os.path.exists(exe_path): + print(f"Found executable at: {exe_path}") + subprocess.Popen([exe_path], shell=True) + exe_launched = True + break + + if not exe_launched: + print(f"ERROR: Could not find {product_name}.exe in any of these locations:") + for path in exe_paths: + abs_path = os.path.abspath(path) + print(f" - {abs_path} (exists: {os.path.exists(abs_path)})") + raise FileNotFoundError(f"Unity executable not found") + time.sleep(10) print(f"{product_name} opened successfully.") @@ -152,8 +632,91 @@ def bring_sample_app_to_foreground(): subprocess.run(command, check=True) time.sleep(10) +def setup_browser_permissions(): + """Set up browser permissions to allow auth.immutable.com to open external applications""" + print("Setting up browser permissions for auth.immutable.com...") + + # Create a browser preferences file to pre-allow the domain + user_data_dir = "C:\\temp\\brave_debug" + if not os.path.exists(user_data_dir): + os.makedirs(user_data_dir, exist_ok=True) + + # Create preferences file that allows auth.immutable.com to open external apps + preferences = { + "profile": { + "content_settings": { + "exceptions": { + "protocol_handler": { + "https://auth.immutable.com,*": { + "setting": 1, + "last_modified": "13000000000000000" + } + } + } + } + } + } + + import json + prefs_file = os.path.join(user_data_dir, "Default", "Preferences") + os.makedirs(os.path.dirname(prefs_file), exist_ok=True) + + try: + with open(prefs_file, 'w') as f: + json.dump(preferences, f, indent=2) + print("Browser permissions configured to allow auth.immutable.com") + except Exception as e: + print(f"Browser permission setup error: {e}") + +def setup_protocol_association(): + """Set up immutablerunner:// protocol association to avoid permission dialogs""" + print("Setting up protocol association for immutablerunner://...") + + # PowerShell script to register the protocol + ps_script = ''' + # Register immutablerunner protocol + $protocolKey = "HKCU:\\Software\\Classes\\immutablerunner" + $commandKey = "$protocolKey\\shell\\open\\command" + + # Create the registry keys + if (!(Test-Path $protocolKey)) { + New-Item -Path $protocolKey -Force | Out-Null + } + if (!(Test-Path $commandKey)) { + New-Item -Path $commandKey -Force | Out-Null + } + + # Set the protocol values + Set-ItemProperty -Path $protocolKey -Name "(Default)" -Value "URL:immutablerunner Protocol" + Set-ItemProperty -Path $protocolKey -Name "URL Protocol" -Value "" + + # Find the Unity sample app executable + $sampleAppPath = "C:\\Immutable\\unity-immutable-sdk\\sample\\build\\Immutable Sample.exe" + if (Test-Path $sampleAppPath) { + Set-ItemProperty -Path $commandKey -Name "(Default)" -Value "`"$sampleAppPath`" `"%1`"" + Write-Host "Protocol association set up successfully" + } else { + Write-Host "Sample app not found at expected path" + } + ''' + + try: + result = subprocess.run(["powershell", "-Command", ps_script], + capture_output=True, text=True, timeout=10) + if "successfully" in result.stdout: + print("Protocol association configured - dialog should not appear!") + else: + print("Protocol setup may have failed, dialog might still appear") + except Exception as e: + print(f"Protocol setup error: {e}") + def launch_browser(): print("Starting Brave...") + + # Set up browser permissions and protocol association first + setup_browser_permissions() + setup_protocol_association() + browser_paths = [ r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe" ] @@ -168,11 +731,47 @@ def launch_browser(): print("Brave executable not found.") exit(1) - subprocess.run([ + # Launch Brave with CI-friendly flags to handle protocol dialogs automatically + browser_args = [ + '--remote-debugging-port=9222', + '--disable-web-security', + '--allow-running-insecure-content', + '--disable-features=VizDisplayCompositor', + '--disable-popup-blocking', + '--no-first-run', + '--disable-default-apps', + '--disable-extensions', + '--disable-component-extensions-with-background-pages', + '--autoplay-policy=no-user-gesture-required', + '--allow-external-protocol-handlers', + '--enable-automation', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding' + ] + + # Check if we're in CI environment + is_ci = os.getenv('CI') or os.getenv('GITHUB_ACTIONS') or os.getenv('BUILD_ID') + if is_ci: + print("CI environment detected - adding additional protocol handling flags") + browser_args.extend([ + '--disable-prompt-on-repost', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--force-permission-policy-unload-default-enabled' + ]) + + args_string = "', '".join(browser_args) + result = subprocess.run([ "powershell.exe", "-Command", - f"Start-Process -FilePath '{browser_path}' -ArgumentList '--remote-debugging-port=9222'" - ], check=True) + f"$process = Start-Process -FilePath '{browser_path}' -ArgumentList '{args_string}' -PassThru; Write-Output $process.Id" + ], capture_output=True, text=True, check=True) + + # Store the debug browser process ID globally for later use + global debug_browser_pid + debug_browser_pid = result.stdout.strip() + print(f"Debug browser launched with PID: {debug_browser_pid}") time.sleep(5) diff --git a/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/Immutable.Browser.Core.asmdef b/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/Immutable.Browser.Core.asmdef index b7969ec46..14b9ad77d 100644 --- a/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/Immutable.Browser.Core.asmdef +++ b/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/Immutable.Browser.Core.asmdef @@ -2,7 +2,8 @@ "name": "Immutable.Browser.Core", "rootNamespace": "Immutable.Browser.Core", "references": [ - "GUID:f51ebe6a0ceec4240a699833d6309b23" + "UniTask", + "Immutable.Passport.Core.Logging" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/WindowsWebBrowserClientAdapter.cs b/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/WindowsWebBrowserClientAdapter.cs index c08ee80ad..efa949343 100644 --- a/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/WindowsWebBrowserClientAdapter.cs +++ b/src/Packages/Passport/Runtime/ThirdParty/ImmutableBrowserCore/WindowsWebBrowserClientAdapter.cs @@ -3,6 +3,7 @@ using System.IO; using UnityEngine; using Immutable.Browser.Core; +using Immutable.Passport.Core.Logging; using Cysharp.Threading.Tasks; namespace Immutable.Browser.Core @@ -50,6 +51,8 @@ public void ExecuteJs(string js) public void LaunchAuthURL(string url, string? redirectUri) { + // Log the auth URL for test automation to capture + PassportLogger.Info($"PASSPORT_AUTH_URL: {url}"); Application.OpenURL(url); }