Skip to content
Draft
21 changes: 21 additions & 0 deletions .github/instructions/uitests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
156 changes: 156 additions & 0 deletions src/TestUtils/src/UITest.Appium/Actions/AppiumScrollActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,6 +38,7 @@ public class AppiumScrollActions : ICommandExecutionGroup
ScrollRightCommand,
ScrollUpCommand,
ScrollUpToCommand,
ScrollToBottomCommand,
};

public AppiumScrollActions(AppiumApp appiumApp)
Expand All @@ -59,6 +61,7 @@ public CommandResponse Execute(string commandName, IDictionary<string, object> p
ScrollRightCommand => ScrollRight(parameters),
ScrollUpCommand => ScrollUp(parameters),
ScrollUpToCommand => ScrollUpTo(parameters),
ScrollToBottomCommand => ScrollToBottom(parameters),
_ => CommandResponse.FailedEmptyResponse,
};
}
Expand Down Expand Up @@ -173,6 +176,24 @@ CommandResponse ScrollUpTo(IDictionary<string, object> parameters)
return CommandResponse.SuccessEmptyResponse;
}

CommandResponse ScrollToBottom(IDictionary<string, object> 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)
Expand Down Expand Up @@ -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<ActionSequence> { 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<ActionSequence> { 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<ActionSequence> { 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,
Expand Down
74 changes: 74 additions & 0 deletions src/TestUtils/src/UITest.Appium/HelperExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,53 @@ internal static void ScrollDownTo(this IApp app, string toMarked, IUIElement? el
}
}

/// <summary>
/// Scrolls to the bottom of a scrollable element by repeatedly scrolling until a bottom indicator element is found.
/// </summary>
/// <param name="app">Represents the main gateway to interact with an app.</param>
/// <param name="scrollElementMarked">Marked selector for the element to scroll (e.g., a list item to use for scroll coordinates).</param>
/// <param name="bottomMarked">Marked selector for the element that indicates the bottom has been reached.</param>
/// <param name="strategy">Strategy for scrolling element.</param>
/// <param name="swipeSpeed">The speed of the scroll gesture in milliseconds.</param>
/// <param name="maxScrolls">Maximum number of scroll attempts before giving up.</param>
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);
}

/// <summary>
/// Scrolls to the bottom of a scrollable element by repeatedly scrolling until a bottom indicator element is found.
/// </summary>
/// <param name="app">Represents the main gateway to interact with an app.</param>
/// <param name="query">Query to locate the element to scroll.</param>
/// <param name="bottomMarked">Marked selector for the element that indicates the bottom has been reached.</param>
/// <param name="strategy">Strategy for scrolling element.</param>
/// <param name="swipeSpeed">The speed of the scroll gesture in milliseconds.</param>
/// <param name="maxScrolls">Maximum number of scroll attempts before giving up.</param>
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<string, object>
{
{ "element", element },
{ "bottomMarked", bottomMarked },
{ "strategy", strategy },
{ "swipeSpeed", swipeSpeed },
{ "maxScrolls", maxScrolls }
});
}
}

/// <summary>
/// Scrolls right on the first element matching query.
/// </summary>
Expand Down Expand Up @@ -2377,6 +2424,33 @@ public static void TapShellFlyoutIcon(this IApp app)
app.TapFlyoutIcon();
}

/// <summary>
/// Closes the flyout by tapping outside the flyout area.
/// This method automatically detects the screen size and taps near the right edge.
/// </summary>
/// <param name="app">Represents the main gateway to interact with an app.</param>
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");
}
}

/// <summary>
/// Taps the Flyout icon for FlyoutPage.
/// </summary>
Expand Down
Loading