diff --git a/sample/Tests/test/test_windows.py b/sample/Tests/test/test_windows.py index b956d6d5..d9d79c5f 100644 --- a/sample/Tests/test/test_windows.py +++ b/sample/Tests/test/test_windows.py @@ -12,7 +12,7 @@ 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, logout_with_controlled_browser +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, get_product_name class WindowsTest(UnityTest): @@ -120,11 +120,56 @@ def _perform_login(self): bring_sample_app_to_foreground() # Wait for authenticated screen - self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene") + # Default AltTester timeout for this command is ~20s; CI often needs longer, + # especially when the browser auto-handles the deep-link without a dialog. + self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene", timeout=90) stop_browser() print("[SUCCESS] Login successful") except Exception as err: + # Dump Player.log tail to help diagnose why the deep-link callback + # wasn't processed (or why Unity failed after receiving it). + try: + import os + product_name = os.getenv("UNITY_APP_NAME", get_product_name()) + log_path = os.path.join( + "C:\\Users\\WindowsBuildsdkServi\\AppData\\LocalLow\\Immutable", + product_name, + "Player.log", + ) + print(f"Attempting to dump Unity Player.log tail: {log_path}") + if os.path.exists(log_path): + with open(log_path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.read().splitlines() + # The tail is often dominated by AltTester noise. Print: + # 1) last lines, and 2) last relevant lines (Passport/Immutable/URLs/errors). + tail = lines[-200:] if len(lines) > 200 else lines + print("----- Player.log (tail) -----") + for line in tail: + print(line) + print("----- end Player.log (tail) -----") + + needles = ( + "immutable", + "passport", + "launchauthurl", + "passport_auth_url", + "immutablerunner", + "error", + "exception", + "gb:", + ) + relevant = [ln for ln in lines if any(n in ln.lower() for n in needles)] + relevant_tail = relevant[-200:] if len(relevant) > 200 else relevant + print("----- Player.log (relevant tail) -----") + for line in relevant_tail: + print(line) + print("----- end Player.log (relevant tail) -----") + else: + print("Player.log not found.") + except Exception as e: + print(f"Failed to dump Player.log: {e}") + stop_browser() raise SystemExit(f"Login failed: {err}") @@ -224,7 +269,7 @@ def test_6_relogin(self): self.get_altdriver().wait_for_object(By.NAME, "ReloginBtn").tap() # Wait for authenticated screen - self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene") + self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene", timeout=90) print("Re-logged in") # Get access token @@ -251,7 +296,7 @@ def test_7_reconnect_connect_imx(self): self.get_altdriver().wait_for_object(By.NAME, "ReconnectBtn").tap() # Wait for authenticated screen - self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene") + self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene", timeout=90) print("Reconnected") # Get access token @@ -304,7 +349,7 @@ def test_8_connect_imx(self): bring_sample_app_to_foreground() # Wait for authenticated screen - self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene") + self.get_altdriver().wait_for_current_scene_to_be("AuthenticatedScene", timeout=90) print("Logged in and connected to IMX") stop_browser() diff --git a/sample/Tests/test/test_windows_helpers.py b/sample/Tests/test/test_windows_helpers.py index f89ee7b1..3a8370b6 100644 --- a/sample/Tests/test/test_windows_helpers.py +++ b/sample/Tests/test/test_windows_helpers.py @@ -36,6 +36,113 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src')) from fetch_otp import EMAIL, fetch_code +# Unity authentication success markers we can reliably detect from Player.log. +_UNITY_AUTH_SUCCESS_MARKERS = [ + "AuthenticatedScene", + "COMPLETE_LOGIN_PKCE", + "LoginPKCESuccess", + "HandleLoginPkceSuccess", + "authentication successful", + "logged in successfully", + "Passport token received", +] + + +def wait_for_authentication_success_in_unity_logs(timeout_seconds: int = 60) -> bool: + """ + Best-effort wait for Unity to process the deep-link callback. + + This is needed in CI because the browser may auto-handle the external protocol + without showing any permission dialog (especially if protocol association is set), + so Selenium won't have any button to click, but Unity will still authenticate. + """ + import os + + product_name = os.getenv("UNITY_APP_NAME", get_product_name()) + log_path = os.path.join("C:\\Users\\WindowsBuildsdkServi\\AppData\\LocalLow\\Immutable", product_name, "Player.log") + + print(f"Waiting up to {timeout_seconds}s for Unity auth success in Player.log...") + print(f"Monitoring Unity log: {log_path}") + + for _ in range(max(1, int(timeout_seconds))): + try: + with open(log_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + if any(marker in content for marker in _UNITY_AUTH_SUCCESS_MARKERS): + print("Authentication success detected in Unity logs!") + return True + except Exception: + # Log may not exist yet or be temporarily locked; retry. + pass + time.sleep(1) + + print("No authentication success detected in Unity logs within timeout.") + return False + + +def try_click_protocol_permission_dialog(timeout_seconds: int = 15) -> bool: + """ + Try to click the browser-level external protocol permission prompt. + + Important: this dialog is NOT part of the web page DOM, so Selenium cannot click it. + We use Windows UIAutomation to locate and invoke an "Open/Allow/Yes" button. + """ + product_name = os.getenv("UNITY_APP_NAME", get_product_name()) + print("Attempting to click external protocol permission dialog via UIAutomation...") + + ps_script = f''' + $deadline = (Get-Date).AddSeconds({int(timeout_seconds)}) + Add-Type -AssemblyName UIAutomationClient + + function Find-And-Click($titlePart) {{ + $procs = Get-Process -ErrorAction SilentlyContinue | Where-Object {{ $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like "*$titlePart*" }} + foreach ($p in $procs) {{ + try {{ + $el = [Windows.Automation.AutomationElement]::FromHandle($p.MainWindowHandle) + if ($null -eq $el) {{ continue }} + $buttons = $el.FindAll([Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.Condition]::new([Windows.Automation.AutomationElement]::ControlTypeProperty, [Windows.Automation.ControlType]::Button)) + + foreach ($b in $buttons) {{ + $name = $b.Current.Name + if ($name -like "*Open*" -or $name -like "*Allow*" -or $name -like "*Yes*" -or $name -like "*{product_name}*") {{ + $b.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke() + Write-Host "Clicked protocol dialog button: $name" + return $true + }} + }} + }} catch {{}} + }} + return $false + }} + + while ((Get-Date) -lt $deadline) {{ + if (Find-And-Click "auth.immutable.com") {{ exit 0 }} + if (Find-And-Click "Immutable Passport") {{ exit 0 }} + if (Find-And-Click "Open") {{ exit 0 }} + Start-Sleep -Milliseconds 500 + }} + Write-Host "No protocol dialog found" + exit 1 + ''' + + try: + result = subprocess.run( + ["powershell", "-Command", ps_script], + capture_output=True, + text=True, + timeout=max(5, int(timeout_seconds) + 5), + ) + if "Clicked protocol dialog button" in (result.stdout or ""): + print(result.stdout.strip()) + print("Successfully clicked protocol dialog via UIAutomation.") + return True + print((result.stdout or "").strip() or "UIAutomation did not find/click any dialog button.") + return False + except Exception as e: + print(f"UIAutomation dialog click failed: {e}") + return False + # Add chrome.exe to environment variable # Download chrome driver and add to environment variable @@ -538,6 +645,8 @@ def login(): time.sleep(3) try: + current_url_lower = (driver.current_url or "").lower() + # Check what's actually on the page buttons = driver.find_elements(By.TAG_NAME, "button") print(f"Found {len(buttons)} buttons on page:") @@ -548,16 +657,86 @@ def login(): print(f" Button {i}: '{text}'") except: pass - - # Wait for the deep link dialog to appear and click the button - # Use more specific selector to avoid clicking "Restore" button + + # The "open external protocol" prompt is often browser UI (not DOM), + # so Selenium can't click it. We try multiple strategies: + # - observe URL transitions (sometimes it flips to new tab/about:blank after protocol launch) + # - inspect page HTML for any immutablerunner:// URL we can trigger manually + # - wait for Unity logs (best source of truth) + # - fall back to UIAutomation click + if "auth.immutable.com/checking" in current_url_lower: + checking_start = time.time() + + for i in range(30): # ~60s, 2s per loop + time.sleep(2) + try: + current = driver.current_url or "" + lower = current.lower() + elapsed = int(time.time() - checking_start) + print(f"/checking monitor (+{elapsed}s) - current URL: {current}") + + if "newtab" in lower or "about:blank" in lower: + print("Detected newtab/about:blank; assuming external protocol launch was attempted.") + wait_for_authentication_success_in_unity_logs(timeout_seconds=75) + return + + # Try to spot a protocol callback URL in the HTML (best-effort). + # Some flows embed the redirect URL or a "continue" link. + try: + html = driver.page_source or "" + if i in (0, 2, 5, 10): # don't spam + print("----- /checking page_source (head) -----") + print(html[:2000]) + print("----- end page_source (head) -----") + + m = re.search(r"(immutablerunner:[^\\s\"'<>]+)", html, re.IGNORECASE) + if m: + callback_url = m.group(1) + print(f"Found immutablerunner URL in page_source: {callback_url}") + print("Attempting to trigger protocol URL via driver.get(...)") + try: + driver.get(callback_url) + except Exception as e: + print(f"driver.get(immutablerunner://...) raised (expected in some browsers): {e}") + # Give Unity time to process + wait_for_authentication_success_in_unity_logs(timeout_seconds=75) + return + except Exception as e: + print(f"Error inspecting /checking page source: {e}") + + if "auth.immutable.com/checking" not in lower: + print("Left /checking; waiting for Unity to process callback...") + wait_for_authentication_success_in_unity_logs(timeout_seconds=75) + return + except Exception as e: + print(f"/checking monitor error: {e}") + + # If we're still here, attempt log-based success and UIAutomation as last resort. + if wait_for_authentication_success_in_unity_logs(timeout_seconds=10): + return + + clicked = try_click_protocol_permission_dialog(timeout_seconds=20) + if clicked: + wait_for_authentication_success_in_unity_logs(timeout_seconds=75) + return + + print("Could not trigger protocol callback from /checking.") + wait_for_authentication_success_in_unity_logs(timeout_seconds=30) + return + + # Otherwise, try to click the deep link dialog if it appears. + # Use more specific selector to avoid clicking "Restore" button. product_name = os.getenv("UNITY_APP_NAME", get_product_name()) - deep_link_button = wait.until(EC.element_to_be_clickable((By.XPATH, f"//button[text()='Open {product_name}.cmd']"))) + dialog_wait = WebDriverWait(driver, 8) + deep_link_button = dialog_wait.until( + EC.element_to_be_clickable((By.XPATH, f"//button[text()='Open {product_name}.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") + print("Assuming dialog was suppressed/auto-handled; waiting for Unity to process callback via Player.log...") + wait_for_authentication_success_in_unity_logs(timeout_seconds=75) # Keep browser alive for Unity deep link redirect # driver.quit() @@ -678,14 +857,11 @@ 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""" +def setup_browser_permissions(user_data_dir: str): + """Set up browser profile 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) + print(f"Using browser profile dir: {user_data_dir}") + os.makedirs(user_data_dir, exist_ok=True) # Create preferences file that allows auth.immutable.com to open external apps preferences = { @@ -714,13 +890,92 @@ def setup_browser_permissions(): except Exception as e: print(f"Browser permission setup error: {e}") + +def setup_autolaunch_protocol_policy(): + """ + Configure Brave enterprise policy to allow external protocol launches without prompting. + + Selenium cannot click the browser-level external protocol permission UI reliably. + This policy avoids the prompt entirely by allowing `immutablerunner:` to auto-launch + from the Passport origin. + + Policy reference (Chromium/Edge naming): + - AutoLaunchProtocolsFromOrigins: JSON array of { protocol, allowed_origins } + - Registry path (Brave): HKCU\\Software\\Policies\\BraveSoftware\\Brave + """ + print("Setting up Brave AutoLaunchProtocolsFromOrigins policy...") + + # This must be REG_SZ containing JSON. + # Protocol must be lowercase and WITHOUT ':' (e.g. "immutablerunner", not "immutablerunner:"). + policy_json = ( + '[{"protocol":"immutablerunner","allowed_origins":["https://auth.immutable.com","https://auth.immutable.com:443"]}]' + ) + + ps_script = f''' + $json = '{policy_json}' + $paths = @( + "HKCU:\\Software\\Policies\\BraveSoftware\\Brave", + "HKLM:\\Software\\Policies\\BraveSoftware\\Brave" + ) + + foreach ($key in $paths) {{ + try {{ + if (!(Test-Path $key)) {{ + New-Item -Path $key -Force | Out-Null + }} + New-ItemProperty -Path $key -Name "AutoLaunchProtocolsFromOrigins" -PropertyType String -Value $json -Force | Out-Null + Write-Host "Brave policy AutoLaunchProtocolsFromOrigins set at $key." + }} catch {{ + # NOTE: avoid "$key:" which PowerShell parses as a drive reference. + Write-Host ("Failed to set policy at {0}: {1}" -f $key, $_.Exception.Message) + }} + }} + ''' + + try: + result = subprocess.run(["powershell", "-Command", ps_script], capture_output=True, text=True, timeout=10) + if result.stdout: + print(result.stdout.strip()) + if result.stderr: + # Non-fatal, but useful for debugging policy issues in CI. + print(result.stderr.strip()) + except Exception as e: + print(f"Policy setup error: {e}") + def setup_protocol_association(): """Set up immutablerunner:// protocol association to avoid permission dialogs""" print("Setting up protocol association for immutablerunner://...") product_name = os.getenv("UNITY_APP_NAME", get_product_name()) + app_path_env = os.getenv("UNITY_APP_PATH") + + # Find the executable path using the same logic as open_sample_app() + exe_paths = [] + if app_path_env: + exe_paths.append(app_path_env) + exe_paths.extend([ + f"{product_name}.exe", + f"../build/{product_name}.exe", + f"./{product_name}.exe", + ]) + + sample_app_path = None + for exe_path in exe_paths: + if os.path.exists(exe_path): + sample_app_path = os.path.abspath(exe_path) + break + + if not sample_app_path: + print(f"WARNING: Could not find Unity executable for protocol handler registration") + print(f"Tried paths: {exe_paths}") + return + + print(f"Registering protocol handler with executable: {sample_app_path}") # PowerShell script to register the protocol + # Escape backslashes for PowerShell + ps_app_path = sample_app_path.replace('\\', '\\\\') + ps_script = f''' # Register immutablerunner protocol $protocolKey = "HKCU:\\Software\\Classes\\immutablerunner" @@ -738,14 +993,10 @@ def setup_protocol_association(): 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\\{product_name}.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" - }} + # Set the command to launch the Unity app with the callback URL + $sampleAppPath = "{ps_app_path}" + Set-ItemProperty -Path $commandKey -Name "(Default)" -Value "`"$sampleAppPath`" `"%1`"" + Write-Host "Protocol association set up successfully for: $sampleAppPath" ''' try: @@ -761,10 +1012,6 @@ def setup_protocol_association(): 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" ] @@ -779,6 +1026,15 @@ def launch_browser(): print("Brave executable not found.") exit(1) + # Create a temporary directory for browser data to ensure clean state + import tempfile + temp_user_data = tempfile.mkdtemp(prefix='brave_test_') + + # IMPORTANT: apply permissions to the SAME profile we launch with. + setup_browser_permissions(user_data_dir=temp_user_data) + setup_protocol_association() + setup_autolaunch_protocol_policy() + # Launch Brave with CI-friendly flags to handle protocol dialogs automatically browser_args = [ '--remote-debugging-port=9222', @@ -791,11 +1047,22 @@ def launch_browser(): '--disable-extensions', '--disable-component-extensions-with-background-pages', '--autoplay-policy=no-user-gesture-required', + # We now rely on enterprise policy + profile permissions to auto-allow protocol launch, + # so enabling external protocol handlers should allow the redirect to fire without + # any UI prompt (Selenium cannot click browser-level prompts reliably). '--allow-external-protocol-handlers', '--enable-automation', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', - '--disable-renderer-backgrounding' + '--disable-renderer-backgrounding', + # Cache-busting flags to ensure fresh session every time + f'--user-data-dir={temp_user_data}', + '--disk-cache-size=0', + '--disable-application-cache', + '--disable-gpu-shader-disk-cache', + '--media-cache-size=0', + '--aggressive-cache-discard', + '--disable-dev-shm-usage' ] # Check if we're in CI environment diff --git a/src/Packages/Passport/Runtime/Resources/index.html b/src/Packages/Passport/Runtime/Resources/index.html index a542457e..5c429a1e 100644 --- a/src/Packages/Passport/Runtime/Resources/index.html +++ b/src/Packages/Passport/Runtime/Resources/index.html @@ -1,4 +1,9 @@ -