diff --git a/.github/instructions/uitests.instructions.md b/.github/instructions/uitests.instructions.md index ebab7a6aa524..5a8545c44394 100644 --- a/.github/instructions/uitests.instructions.md +++ b/.github/instructions/uitests.instructions.md @@ -250,6 +250,27 @@ pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "Issue1234 - It captures logs automatically for debugging - Manual `dotnet` commands often fail due to missing environment setup +**🚨 CRITICAL: NEVER Delete Build Artifacts** + +**YOU MUST NEVER DELETE THE HOSTAPP BUILD ARTIFACTS AS A "SOLUTION"** + +- ❌ **NEVER** run `rm -rf artifacts/bin/Controls.TestCases.HostApp/` +- ❌ **NEVER** run `rm -rf artifacts/obj/Controls.TestCases.HostApp/` +- ❌ **NEVER** delete build artifacts to "force a rebuild" +- ❌ **NEVER** clean the build as a troubleshooting step + +**ALWAYS assume:** +- ✅ If BuildAndRunHostApp.ps1 completes without build errors, a new app was correctly built and deployed +- ✅ The build system handles incremental builds correctly +- ✅ If the test passes/fails, it's testing the current code state + +**If a test result seems wrong:** +- ✅ **DO** review your test logic first +- ✅ **DO** check if your test assertions are correct +- ✅ **DO** verify your test is actually validating the right behavior +- ✅ **DO** ask for guidance if confused +- ❌ **DO NOT** assume stale binaries - the build system works correctly + ### Prerequisites: Kill Existing Appium Processes **CRITICAL**: Before running UITests with BuildAndRunHostApp.ps1, always kill any existing Appium processes. The UITest framework needs to start its own Appium server, and having a stale process running will cause the tests to fail with an error like: diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FlyoutFooterAreaClearedAfterRemoval.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FlyoutFooterAreaClearedAfterRemoval.png new file mode 100644 index 000000000000..d16cb19d3f97 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/FlyoutFooterAreaClearedAfterRemoval.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/ShellFlyoutContent.cs b/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/ShellFlyoutContent.cs index a42484143ed9..e07576740d06 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/ShellFlyoutContent.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/ShellFlyoutContent.cs @@ -18,7 +18,8 @@ protected override void Init() } Items.Add(new MenuItem() { Text = "Menu Item" }); - AddFlyoutItem("Flyout Item Bottom"); + var bottomItem = AddFlyoutItem("Flyout Item Bottom"); + bottomItem.AutomationId = "Flyout Item Bottom"; var layout = new StackLayout() { diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellFlyoutContent.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellFlyoutContent.cs index 3ec1325e3f0b..05924f6b3bb9 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellFlyoutContent.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/ShellFlyoutContent.cs @@ -34,4 +34,46 @@ public void FlyoutContentTests() App.TapInShellFlyout(ResetButton); App.Tap(FlyoutItem); } + + // https://github.com/dotnet/maui/issues/32883 + [Test] + [Category(UITestCategories.Shell)] + public void FlyoutFooterAreaClearedAfterRemoval() + { + App.WaitForElement("PageLoaded"); + + // The bug: When footer is removed, UpdateContentPadding() is NOT called, + // so the bottom padding that was added for the footer remains. + // This leaves extra empty space at the bottom of the flyout, preventing + // proper scrolling and content positioning. + // + // Test strategy: Verify flyout footer area is properly cleared after removal: + // 1. Add header/footer to flyout + // 2. Remove header/footer (bug occurs here - padding not cleared) + // 3. Open flyout and verify content positioning + // 4. Scroll to bottom item to confirm proper layout restoration + // + // With bug: Extra padding remains + // With fix: Padding is cleared and content positions correctly + + // Step 1: Add header/footer to flyout + App.Tap("ToggleHeaderFooter"); + App.TapShellFlyoutIcon(); + App.WaitForElement(FlyoutItem); + + // Close flyout + App.CloseFlyout(); + + // Step 2: Remove header/footer - THIS IS WHERE THE BUG MANIFESTS + App.Tap("ToggleHeaderFooter"); + + // Step 3: Open flyout and verify content positioning + App.TapShellFlyoutIcon(); + App.WaitForElement(FlyoutItem); + + // Step 4: Scroll to bottom item to confirm proper layout restoration + App.ScrollToBottom(FlyoutItem, "Flyout Item Bottom", strategy: ScrollStrategy.Gesture, swipeSpeed: 50, maxScrolls: 100); + + VerifyScreenshot(); + } } \ No newline at end of file diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/FlyoutFooterAreaClearedAfterRemoval.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/FlyoutFooterAreaClearedAfterRemoval.png new file mode 100644 index 000000000000..da3ecc376db1 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/FlyoutFooterAreaClearedAfterRemoval.png differ diff --git a/src/TestUtils/src/UITest.Appium/Actions/AppiumCatalystSwipeActions.cs b/src/TestUtils/src/UITest.Appium/Actions/AppiumCatalystSwipeActions.cs index 775d3d40da73..f758c8bae5a8 100644 --- a/src/TestUtils/src/UITest.Appium/Actions/AppiumCatalystSwipeActions.cs +++ b/src/TestUtils/src/UITest.Appium/Actions/AppiumCatalystSwipeActions.cs @@ -19,7 +19,7 @@ protected override void SwipeToRight(AppiumDriver driver, AppiumElement? element int endX = (int)(position.X + (size.Width * swipePercentage)); int endY = startY; - var finger = new PointerInputDevice(PointerKind.Mouse); + var finger = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Mouse); var sequence = new ActionSequence(finger, 0); sequence.AddAction(finger.CreatePointerMove(CoordinateOrigin.Viewport, startX, startY, @@ -44,7 +44,7 @@ protected override void SwipeToLeft(AppiumDriver driver, AppiumElement? element, int endX = (int)(position.X + (size.Width * 0.05)); int endY = startY; - var finger = new PointerInputDevice(PointerKind.Mouse); + var finger = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Mouse); var sequence = new ActionSequence(finger, 0); sequence.AddAction(finger.CreatePointerMove(CoordinateOrigin.Viewport, startX, startY, diff --git a/src/TestUtils/src/UITest.Appium/Actions/AppiumScrollActions.cs b/src/TestUtils/src/UITest.Appium/Actions/AppiumScrollActions.cs index 48965cbdafb2..2a176ab17cfb 100644 --- a/src/TestUtils/src/UITest.Appium/Actions/AppiumScrollActions.cs +++ b/src/TestUtils/src/UITest.Appium/Actions/AppiumScrollActions.cs @@ -26,6 +26,7 @@ public class AppiumScrollActions : ICommandExecutionGroup const string ScrollRightCommand = "scrollRight"; const string ScrollUpCommand = "scrollUp"; const string ScrollUpToCommand = "scrollUpTo"; + const string ScrollToBottomCommand = "scrollToBottom"; readonly AppiumApp _appiumApp; @@ -37,6 +38,7 @@ public class AppiumScrollActions : ICommandExecutionGroup ScrollRightCommand, ScrollUpCommand, ScrollUpToCommand, + ScrollToBottomCommand, }; public AppiumScrollActions(AppiumApp appiumApp) @@ -59,6 +61,7 @@ public CommandResponse Execute(string commandName, IDictionary p ScrollRightCommand => ScrollRight(parameters), ScrollUpCommand => ScrollUp(parameters), ScrollUpToCommand => ScrollUpTo(parameters), + ScrollToBottomCommand => ScrollToBottom(parameters), _ => CommandResponse.FailedEmptyResponse, }; } @@ -173,6 +176,24 @@ CommandResponse ScrollUpTo(IDictionary parameters) return CommandResponse.SuccessEmptyResponse; } + CommandResponse ScrollToBottom(IDictionary parameters) + { + parameters.TryGetValue("element", out var value); + var element = GetAppiumElement(value); + + if (element is null) + return CommandResponse.FailedEmptyResponse; + + string bottomMarked = (string)parameters["bottomMarked"]; + ScrollStrategy strategy = (ScrollStrategy)parameters["strategy"]; + int swipeSpeed = (int)parameters["swipeSpeed"]; + int maxScrolls = (int)parameters["maxScrolls"]; + + PerformScrollToBottom(_appiumApp.Driver, element, bottomMarked, strategy, swipeSpeed, maxScrolls); + + return CommandResponse.SuccessEmptyResponse; + } + static AppiumElement? GetAppiumElement(object? element) { if (element is AppiumElement appiumElement) @@ -317,6 +338,141 @@ void ScrollToUp(AppiumDriver driver, AppiumElement element, ScrollStrategy strat return result; } + void PerformScrollToBottom(AppiumDriver driver, AppiumElement scrollElement, string bottomMarked, ScrollStrategy strategy, int swipeSpeed, int maxScrolls) + { + // Get scroll coordinates once from the scroll element + var position = scrollElement.Location; + var size = scrollElement.Size; + + int startX = position.X + size.Width / 2; + int startY = position.Y + size.Height - 10; + int endX = startX; + int endY = position.Y + 10; + + // Check if bottom element is already visible AND already at bottom (can't scroll further) + bool foundBottom = false; + bool alreadyAtBottom = false; + try + { + var element = driver.FindElement(By.XPath("//*[@text='" + bottomMarked + "' or @label='" + bottomMarked + "' or @Name='" + bottomMarked + "']")); + if (element != null) + { + foundBottom = true; + + // Check if we can scroll further by attempting a scroll and checking if position changes + int yPositionBefore = element.Location.Y; + + // Perform a test scroll + OpenQA.Selenium.Appium.Interactions.PointerInputDevice touchDevice = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Touch); + var swipeSequence = new ActionSequence(touchDevice, 0); + swipeSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, startX, startY, TimeSpan.Zero)); + swipeSequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); + swipeSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, endX, endY, TimeSpan.FromMilliseconds(swipeSpeed))); + swipeSequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); + driver.PerformActions(new List { swipeSequence }); + + System.Threading.Thread.Sleep(50); + + // Check if position changed + var elementAfter = driver.FindElement(By.XPath("//*[@text='" + bottomMarked + "' or @label='" + bottomMarked + "' or @Name='" + bottomMarked + "']")); + int yPositionAfter = elementAfter.Location.Y; + + if (Math.Abs(yPositionAfter - yPositionBefore) <= 5) + { + // Position didn't change, we're already at the bottom + alreadyAtBottom = true; + } + else + { + // Position changed, need to continue scrolling + foundBottom = false; + } + } + } + catch + { + // Element not visible yet, need to scroll + } + + // If already at bottom and can't scroll further, exit early + if (alreadyAtBottom) + { + // Already at bottom, no more scrolling needed + return; + } + + for (int i = 0; i < maxScrolls && !foundBottom; i++) + { + // Use direct Appium Actions API for fast, precise scrolling + OpenQA.Selenium.Appium.Interactions.PointerInputDevice touchDevice = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Touch); + var swipeSequence = new ActionSequence(touchDevice, 0); + swipeSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, startX, startY, TimeSpan.Zero)); + swipeSequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); + swipeSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, endX, endY, TimeSpan.FromMilliseconds(swipeSpeed))); + swipeSequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); + driver.PerformActions(new List { swipeSequence }); + + // Brief delay to let UI settle after scroll + System.Threading.Thread.Sleep(50); + + // Check if we reached the bottom by looking for the bottom element + try + { + var element = driver.FindElement(By.XPath("//*[@text='" + bottomMarked + "' or @label='" + bottomMarked + "' or @Name='" + bottomMarked + "']")); + if (element != null) + { + foundBottom = true; + } + } + catch + { + // Element not found yet, continue scrolling + } + } + + if (!foundBottom) + { + throw new InvalidOperationException($"Could not find bottom element '{bottomMarked}' after {maxScrolls} scroll attempts"); + } + + // Perform final validation scroll + PerformFinalValidationScroll(driver, startX, startY, endX, endY, bottomMarked, swipeSpeed); + } + + void PerformFinalValidationScroll(AppiumDriver driver, int startX, int startY, int endX, int endY, string bottomMarked, int swipeSpeed) + { + // Perform one final scroll to ensure we're fully at the bottom + // and validate the position doesn't change (meaning we're truly at bottom) + try + { + var elementBeforeFinalScroll = driver.FindElement(By.XPath("//*[@text='" + bottomMarked + "' or @label='" + bottomMarked + "' or @Name='" + bottomMarked + "']")); + int yPositionBefore = elementBeforeFinalScroll.Location.Y; + + // Perform one more scroll + OpenQA.Selenium.Appium.Interactions.PointerInputDevice touchDevice = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Touch); + var swipeSequence = new ActionSequence(touchDevice, 0); + swipeSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, startX, startY, TimeSpan.Zero)); + swipeSequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); + swipeSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, endX, endY, TimeSpan.FromMilliseconds(swipeSpeed))); + swipeSequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); + driver.PerformActions(new List { swipeSequence }); + + // Verify position hasn't changed (we're at the bottom) + var elementAfterFinalScroll = driver.FindElement(By.XPath("//*[@text='" + bottomMarked + "' or @label='" + bottomMarked + "' or @Name='" + bottomMarked + "']")); + int yPositionAfter = elementAfterFinalScroll.Location.Y; + + // Position should be the same or very close (within a few pixels for rendering differences) + if (Math.Abs(yPositionAfter - yPositionBefore) > 5) + { + throw new InvalidOperationException($"Final scroll validation failed. Element moved from Y={yPositionBefore} to Y={yPositionAfter}. May not be at true bottom."); + } + } + catch (Exception ex) when (!(ex is InvalidOperationException)) + { + // If we can't validate, that's okay - we found the element which is the main goal + } + } + virtual protected void PerformActions( AppiumDriver driver, int startX, diff --git a/src/TestUtils/src/UITest.Appium/HelperExtensions.cs b/src/TestUtils/src/UITest.Appium/HelperExtensions.cs index f11f2ef8fd35..a9e620d6075e 100644 --- a/src/TestUtils/src/UITest.Appium/HelperExtensions.cs +++ b/src/TestUtils/src/UITest.Appium/HelperExtensions.cs @@ -1291,6 +1291,53 @@ internal static void ScrollDownTo(this IApp app, string toMarked, IUIElement? el } } + /// + /// Scrolls to the bottom of a scrollable element by repeatedly scrolling until a bottom indicator element is found. + /// + /// Represents the main gateway to interact with an app. + /// Marked selector for the element to scroll (e.g., a list item to use for scroll coordinates). + /// Marked selector for the element that indicates the bottom has been reached. + /// Strategy for scrolling element. + /// The speed of the scroll gesture in milliseconds. + /// Maximum number of scroll attempts before giving up. + public static void ScrollToBottom(this IApp app, string scrollElementMarked, string bottomMarked, ScrollStrategy strategy = ScrollStrategy.Gesture, int swipeSpeed = 50, int maxScrolls = 100) + { + var scrollElement = FindElement(app, scrollElementMarked); + + app.ScrollToBottom(scrollElement, bottomMarked, strategy, swipeSpeed, maxScrolls); + } + + /// + /// Scrolls to the bottom of a scrollable element by repeatedly scrolling until a bottom indicator element is found. + /// + /// Represents the main gateway to interact with an app. + /// Query to locate the element to scroll. + /// Marked selector for the element that indicates the bottom has been reached. + /// Strategy for scrolling element. + /// The speed of the scroll gesture in milliseconds. + /// Maximum number of scroll attempts before giving up. + public static void ScrollToBottom(this IApp app, IQuery query, string bottomMarked, ScrollStrategy strategy = ScrollStrategy.Gesture, int swipeSpeed = 50, int maxScrolls = 100) + { + var scrollElement = app.FindElement(query); + + app.ScrollToBottom(scrollElement, bottomMarked, strategy, swipeSpeed, maxScrolls); + } + + internal static void ScrollToBottom(this IApp app, IUIElement? element, string bottomMarked, ScrollStrategy strategy = ScrollStrategy.Gesture, int swipeSpeed = 50, int maxScrolls = 100) + { + if (element is not null) + { + app.CommandExecutor.Execute("scrollToBottom", new Dictionary + { + { "element", element }, + { "bottomMarked", bottomMarked }, + { "strategy", strategy }, + { "swipeSpeed", swipeSpeed }, + { "maxScrolls", maxScrolls } + }); + } + } + /// /// Scrolls right on the first element matching query. /// @@ -2377,6 +2424,33 @@ public static void TapShellFlyoutIcon(this IApp app) app.TapFlyoutIcon(); } + /// + /// Closes the flyout by tapping outside the flyout area. + /// This method automatically detects the screen size and taps near the right edge. + /// + /// Represents the main gateway to interact with an app. + public static void CloseFlyout(this IApp app) + { + // Get screen dimensions from the app driver + if (app is AppiumApp appiumApp && appiumApp.Driver != null) + { + var driver = appiumApp.Driver; + var windowSize = driver.Manage().Window.Size; + + // Tap very close to the right edge to ensure we're outside the flyout + // Use 10px from right edge to avoid any flyout items + int tapX = windowSize.Width - 10; + int tapY = windowSize.Height / 2; + + app.TapCoordinates(tapX, tapY); + System.Threading.Thread.Sleep(500); // Wait for flyout to close + } + else + { + throw new InvalidOperationException("App must be an AppiumApp to close flyout"); + } + } + /// /// Taps the Flyout icon for FlyoutPage. ///