From fb68f01f9266fb935bb0769799252fd247d58d22 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Tue, 25 Feb 2025 09:22:11 +0530 Subject: [PATCH 01/14] fixcode update --- .../Android/Extensions/ToolbarExtensions.cs | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index eb8e5dd7a673..330875deb8e5 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -77,13 +77,13 @@ public static void UpdateTitleIcon(this AToolbar nativeToolbar, Toolbar toolbar) public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar) { - if (toolbar.BackButtonVisible) - { - var context = + var context = nativeToolbar.Context?.GetThemedContext() ?? nativeToolbar.Context ?? toolbar.Handler?.MauiContext?.Context; + if (toolbar.BackButtonVisible) + { nativeToolbar.NavigationIcon ??= new DrawerArrowDrawable(context!); if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) iconDrawable.Progress = 1; @@ -103,17 +103,11 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar } else { - if (!toolbar.DrawerToggleVisible) - { - nativeToolbar.NavigationIcon = null; - } - else - { - if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) - iconDrawable.Progress = 0; + nativeToolbar.NavigationIcon = new DrawerArrowDrawable(context!); + if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) + iconDrawable.Progress = 0; - nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); - } + nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); } nativeToolbar.UpdateIconColor(toolbar); From 6b446335694d8405a031eee407cc8913027199dd Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Fri, 28 Feb 2025 10:50:36 +0530 Subject: [PATCH 02/14] Fix and test case commit --- .../Shell/Android/ShellToolbarTracker.cs | 9 +- .../Shell/iOS/ShellPageRendererTracker.cs | 5 +- .../NavigationPage/NavigationPageToolbar.cs | 3 +- .../Android/Extensions/ToolbarExtensions.cs | 15 ++- .../TestCases.HostApp/Issues/Issue21646.cs | 82 ++++++++++++++++ .../Issues/Issue21646_ShellFlyout.cs | 94 +++++++++++++++++++ .../Tests/Issues/Issue21646.cs | 24 +++++ .../Tests/Issues/Issue21646_ShellFlyout.cs | 24 +++++ 8 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646_ShellFlyout.cs diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index 202234f0c388..6123ec865229 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -102,6 +102,12 @@ public bool CanNavigateBack { get { + var backButtonBehavior = Shell.GetBackButtonBehavior(Page); + var isVisible = backButtonBehavior.GetPropertyIfSet(BackButtonBehavior.IsVisibleProperty, true); + + if (!isVisible) + return false; + if (_page?.Navigation?.NavigationStack?.Count > 1) return true; @@ -483,7 +489,7 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t if (backButtonVisibleFromBehavior && (backButtonVisible || !defaultDrawerArrowDrawable)) toolbar.NavigationIcon = icon; } - else if (_flyoutBehavior == FlyoutBehavior.Flyout || !defaultDrawerArrowDrawable) + else if (_flyoutBehavior == FlyoutBehavior.Flyout || (!defaultDrawerArrowDrawable && backButtonVisible)) { bool drawerEnabled = isEnabled && icon != null; _drawerToggle.DrawerIndicatorEnabled = drawerEnabled; @@ -499,6 +505,7 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t else { _drawerToggle.DrawerIndicatorEnabled = false; + toolbar.NavigationIcon = null; } _drawerToggle.SyncState(); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs index 519dd99f91b5..38a4aa67eae9 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs @@ -520,7 +520,8 @@ void UpdateLeftToolbarItems() } } } - else if (String.IsNullOrWhiteSpace(text) && IsRootPage && _flyoutBehavior == FlyoutBehavior.Flyout) + // Show hamburger icon if it's the root page, or if the back button is not visible. + else if (String.IsNullOrWhiteSpace(text) && (IsRootPage || !backButtonVisible) && _flyoutBehavior == FlyoutBehavior.Flyout) { icon = DrawHamburger(); } @@ -528,7 +529,7 @@ void UpdateLeftToolbarItems() if (icon != null) { NavigationItem.LeftBarButtonItem = - new UIBarButtonItem(icon, UIBarButtonItemStyle.Plain, (s, e) => LeftBarButtonItemHandler(ViewController, IsRootPage)) { Enabled = enabled }; + new UIBarButtonItem(icon, UIBarButtonItemStyle.Plain, (s, e) => LeftBarButtonItemHandler(ViewController, (IsRootPage || !backButtonVisible))) { Enabled = enabled }; } else { diff --git a/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs b/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs index 54c9ff6e41b9..50a7cb3f0e49 100644 --- a/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs +++ b/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs @@ -180,9 +180,10 @@ void UpdateBackButton() // Set this before BackButtonVisible triggers an update to the handler // This way all useful information is present + // Show drawer toggle if it's a FlyoutPage, toolbar button should be shown, and either no pages are pushed or back button is not visible if (Parent is FlyoutPage flyout && flyout.ShouldShowToolbarButton() #if !WINDOWS // TODO NET 10 : Move this logic to ShouldShowToolbarButton - && !anyPagesPushed.Value + && (!anyPagesPushed.Value || !BackButtonVisible) #endif ) _drawerToggleVisible = true; diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index 330875deb8e5..172a7dd15076 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -103,11 +103,18 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar } else { - nativeToolbar.NavigationIcon = new DrawerArrowDrawable(context!); - if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) - iconDrawable.Progress = 0; + if (!toolbar.DrawerToggleVisible) + { + nativeToolbar.NavigationIcon = null; + } + else + { + nativeToolbar.NavigationIcon = new DrawerArrowDrawable(context!); + if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) + iconDrawable.Progress = 0; - nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); + nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); + } } nativeToolbar.UpdateIconColor(toolbar); diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs new file mode 100644 index 000000000000..d3b5ce12bcd8 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs @@ -0,0 +1,82 @@ +using Microsoft.Maui.Controls; +using System; + +namespace Maui.Controls.Sample.Issues +{ + [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a NavigationPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android)] + public partial class Issue21646 : FlyoutPage + { + public Issue21646() + { + FlyoutLayoutBehavior = FlyoutLayoutBehavior.Popover; + var flyoutPage = new ContentPage + { + Title = "Menu", + BackgroundColor = Colors.LightGray, + Content = new StackLayout + { + Padding = new Thickness(20), + Children = + { + new Label { Text = "Flyout Menu", FontSize = 20 }, + new Button { Text = "Go to Home", Command = new Command(GoToHomePage) } + } + } + }; + + var navigateButton = new Button { Text = "Go to Next Page", AutomationId="NavigateToNextPageButton" }; + navigateButton.Clicked += OnNavigateButtonClicked; + + var detailPage = new NavigationPage(new ContentPage + { + Content = new StackLayout + { + Children = + { + new Label { Text = "Welcome to Home Page!", FontSize = 24 }, + navigateButton + } + } + }); + + Flyout = flyoutPage; + Detail = detailPage; + } + + private void OnNavigateButtonClicked(object sender, EventArgs e) + { + if (Detail is NavigationPage navPage) + { + PushPageNoBackAsync(navPage, new ContentPage + { + Title = "Second Page", + Content = new StackLayout + { + Children = { new Label { Text = "This is the second page!", AutomationId="SecondPageLabel" } } + } + }); + } + } + + public static async void PushPageNoBackAsync(NavigationPage np, Page p) + { + NavigationPage.SetHasBackButton(p, false); + await np.PushAsync(p); + } + + private void GoToHomePage() + { + if (Detail is NavigationPage navPage) + { + Detail = new NavigationPage(new ContentPage + { + Content = new StackLayout + { + Children = { new Label { Text = "Welcome Back to Home!" } } + } + }); + IsPresented = false; + } + } + } +} diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs new file mode 100644 index 000000000000..455839b858d0 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs @@ -0,0 +1,94 @@ +using Microsoft.Maui.Controls; +using System; + +namespace Maui.Controls.Sample.Issues +{ + [Issue(IssueTracker.Github, 216468, "Flyout icon should remain visible when a page is pushed onto a ShellPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android)] + public class Issue21646_ShellFlyout : Shell + { + public Issue21646_ShellFlyout() + { + FlyoutHeader = new StackLayout + { + Padding = new Thickness(20), + BackgroundColor = Colors.LightGray, + Children = + { + new Label + { + Text = "Flyout Menu", + FontSize = 20 + } + } + }; + + Items.Add(new ShellContent + { + Title = "Home", + Route = "home", + Content = new HomePage() + }); + + Items.Add(new ShellContent + { + Title = "Second Page", + Route = "second", + Content = new SecondPage() + }); + } + } + + public class HomePage : ContentPage + { + public HomePage() + { + Routing.RegisterRoute("second", typeof(SecondPage)); + + Content = new StackLayout + { + Children = + { + new Label + { + Text = "Welcome to Home Page!", + FontSize = 24 + }, + new Button + { + Text = "Go to Next Page", + Command = new Command(async () => await OnNavigateButtonClicked()) + } + } + }; + } + + private async Task OnNavigateButtonClicked() + { + await PushPageNoBackAsync(new SecondPage()); + } + + public static async Task PushPageNoBackAsync(Page p) + { + Shell.SetBackButtonBehavior(p, new BackButtonBehavior { IsVisible = false }); + await Shell.Current.Navigation.PushAsync(p); + } + } + + public class SecondPage : ContentPage + { + public SecondPage() + { + Content = new StackLayout + { + Children = + { + new Label + { + Text = "This is the second page!", + FontSize = 24 + } + } + }; + } + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs new file mode 100644 index 000000000000..f176092f0a5e --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs @@ -0,0 +1,24 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue21646: _IssuesUITest +{ + public Issue21646(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "Flyout icon should remain visible when a page is pushed onto a NavigationPage with the back button disabled."; + + [Test] + [Category(UITestCategories.Navigation)] + public void FlyoutIconShouldBeVisibleWithBackButtonDisabledInNavigationPage() + { + App.WaitForElement("NavigateToNextPageButton"); + App.Tap("NavigateToNextPageButton"); + App.WaitForElement("SecondPageLabel"); + App.WaitForFlyoutIcon(FlyoutIconAutomationId); + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646_ShellFlyout.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646_ShellFlyout.cs new file mode 100644 index 000000000000..0cef79a995a3 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646_ShellFlyout.cs @@ -0,0 +1,24 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue21646_ShellFlyout: _IssuesUITest +{ + public Issue21646_ShellFlyout(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "Flyout icon should remain visible when a page is pushed onto a ShellPage with the back button disabled."; + + [Test] + [Category(UITestCategories.Navigation)] + public void FlyoutIconShouldBeVisibleWithBackButtonDisabledInShellPage() + { + App.WaitForElement("NavigateToNextPageButton"); + App.Tap("NavigateToNextPageButton"); + App.WaitForElement("SecondPageLabel"); + App.WaitForFlyoutIcon(FlyoutIconAutomationId); + } +} \ No newline at end of file From a334289eb8631d508083beeea1ac3e637c624e48 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Wed, 5 Mar 2025 19:09:40 +0530 Subject: [PATCH 03/14] test case code updated --- .../TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs | 6 ++++-- .../tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs index 455839b858d0..b8c884d213a0 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs @@ -56,7 +56,8 @@ public HomePage() new Button { Text = "Go to Next Page", - Command = new Command(async () => await OnNavigateButtonClicked()) + Command = new Command(async () => await OnNavigateButtonClicked()), + AutomationId = "NavigateToNextPageButton" } } }; @@ -85,7 +86,8 @@ public SecondPage() new Label { Text = "This is the second page!", - FontSize = 24 + FontSize = 24, + AutomationId = "SecondPageLabel" } } }; diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs index f176092f0a5e..63c873e826b6 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs @@ -19,6 +19,6 @@ public void FlyoutIconShouldBeVisibleWithBackButtonDisabledInNavigationPage() App.WaitForElement("NavigateToNextPageButton"); App.Tap("NavigateToNextPageButton"); App.WaitForElement("SecondPageLabel"); - App.WaitForFlyoutIcon(FlyoutIconAutomationId); + App.WaitForFlyoutIcon(FlyoutIconAutomationId, false); } } \ No newline at end of file From e9df09a794b4147e57922047da65d1aeff89eea6 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Thu, 6 Mar 2025 12:11:36 +0530 Subject: [PATCH 04/14] updated automationid in test case --- .../Handlers/Shell/iOS/ShellPageRendererTracker.cs | 2 +- .../TestCases.Shared.Tests/Tests/Issues/Issue21646.cs | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs index 38a4aa67eae9..35314fbcfc22 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs @@ -541,7 +541,7 @@ void UpdateLeftToolbarItems() { if (String.IsNullOrWhiteSpace(image?.AutomationId)) { - if (IsRootPage) + if (IsRootPage || !backButtonVisible) { NavigationItem.LeftBarButtonItem.AccessibilityIdentifier = "OK"; NavigationItem.LeftBarButtonItem.AccessibilityLabel = "Menu"; diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs index 63c873e826b6..4099ab4d5712 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs @@ -6,6 +6,12 @@ namespace Microsoft.Maui.TestCases.Tests.Issues; public class Issue21646: _IssuesUITest { + +#if IOS || MACCATALYST + protected const string FlyoutIconId = "Menu"; +#else + protected const string FlyoutIconId = "OK"; +#endif public Issue21646(TestDevice testDevice) : base(testDevice) { } @@ -19,6 +25,6 @@ public void FlyoutIconShouldBeVisibleWithBackButtonDisabledInNavigationPage() App.WaitForElement("NavigateToNextPageButton"); App.Tap("NavigateToNextPageButton"); App.WaitForElement("SecondPageLabel"); - App.WaitForFlyoutIcon(FlyoutIconAutomationId, false); + App.WaitForFlyoutIcon(FlyoutIconId, false); } } \ No newline at end of file From a3e6ef48e642eb1a26e85850213b1dd9cda29e9c Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Thu, 13 Mar 2025 16:07:52 +0530 Subject: [PATCH 05/14] updated the fix condition --- .../Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index 6123ec865229..ab7ef711e457 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -103,7 +103,7 @@ public bool CanNavigateBack get { var backButtonBehavior = Shell.GetBackButtonBehavior(Page); - var isVisible = backButtonBehavior.GetPropertyIfSet(BackButtonBehavior.IsVisibleProperty, true); + var isVisible = backButtonBehavior?.GetPropertyIfSet(BackButtonBehavior.IsVisibleProperty, true) ?? false; if (!isVisible) return false; From 3ba24840e8def99d590b9b5f16e042bcd2c15ea8 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Tue, 25 Feb 2025 09:22:11 +0530 Subject: [PATCH 06/14] fixcode update --- .../src/Core/Platform/Android/Extensions/ToolbarExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index 172a7dd15076..ed4b4a60aeee 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -113,8 +113,7 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) iconDrawable.Progress = 0; - nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); - } + nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); } nativeToolbar.UpdateIconColor(toolbar); From 5600b3de0630562ca29eeb34728637974c8872b8 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Fri, 28 Feb 2025 10:50:36 +0530 Subject: [PATCH 07/14] Fix and test case commit --- .../src/Core/Platform/Android/Extensions/ToolbarExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index ed4b4a60aeee..172a7dd15076 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -113,7 +113,8 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) iconDrawable.Progress = 0; - nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); + nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); + } } nativeToolbar.UpdateIconColor(toolbar); From 979d8438fba92e65bbc42816b0bbda8e177020aa Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Tue, 9 Dec 2025 18:38:12 +0530 Subject: [PATCH 08/14] resolving conflicts --- .../Handlers/Shell/Android/ShellToolbarTracker.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index ab7ef711e457..8752ddff4058 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -473,16 +473,16 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t icon = _flyoutIconDrawerDrawable; } - if (icon == null && (_flyoutBehavior == FlyoutBehavior.Flyout || CanNavigateBack)) + if (icon == null && (_flyoutBehavior == FlyoutBehavior.Flyout || CanNavigateBack && backButtonVisible)) { _drawerArrowDrawable ??= new DrawerArrowDrawable(context.GetThemedContext()); icon = _drawerArrowDrawable; defaultDrawerArrowDrawable = true; } - icon?.Progress = (CanNavigateBack) ? 1 : 0; + icon?.Progress = (CanNavigateBack && backButtonVisible) ? 1 : 0; - if (command != null || CanNavigateBack) + if (command != null || CanNavigateBack && backButtonVisible) { _drawerToggle.DrawerIndicatorEnabled = false; @@ -560,7 +560,7 @@ protected virtual void UpdateToolbarIconAccessibilityText(AToolbar toolbar, Shel else if (image == null || toolbar.SetNavigationContentDescription(image) == null) { - if (CanNavigateBack) + if (CanNavigateBack && _toolbar?.BackButtonVisible == true) toolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_navigate_up_description); else toolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); From 1749ecdd0b7198c53749d9a2d0f50fb2898bd32e Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Tue, 9 Dec 2025 18:42:02 +0530 Subject: [PATCH 09/14] resolving branch conflicts --- .../Handlers/Shell/Android/ShellToolbarTracker.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index 8752ddff4058..626c9b96c3f9 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -102,12 +102,6 @@ public bool CanNavigateBack { get { - var backButtonBehavior = Shell.GetBackButtonBehavior(Page); - var isVisible = backButtonBehavior?.GetPropertyIfSet(BackButtonBehavior.IsVisibleProperty, true) ?? false; - - if (!isVisible) - return false; - if (_page?.Navigation?.NavigationStack?.Count > 1) return true; From 439b887959905838b6c913b98a46ad0d743a3576 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Mon, 16 Feb 2026 16:46:49 +0530 Subject: [PATCH 10/14] updating as per AI concerns. --- .../Handlers/Shell/Android/ShellToolbarTracker.cs | 6 +++--- .../src/Core/NavigationPage/NavigationPageToolbar.cs | 4 +++- .../Core/Platform/Android/Extensions/ToolbarExtensions.cs | 2 ++ src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs | 2 +- .../TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index 626c9b96c3f9..f9d01b7e6cb4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -467,16 +467,16 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t icon = _flyoutIconDrawerDrawable; } - if (icon == null && (_flyoutBehavior == FlyoutBehavior.Flyout || CanNavigateBack && backButtonVisible)) + if (icon == null && (_flyoutBehavior == FlyoutBehavior.Flyout || (CanNavigateBack && backButtonVisible))) { _drawerArrowDrawable ??= new DrawerArrowDrawable(context.GetThemedContext()); icon = _drawerArrowDrawable; defaultDrawerArrowDrawable = true; } - icon?.Progress = (CanNavigateBack && backButtonVisible) ? 1 : 0; + icon?.Progress = (CanNavigateBack && backButtonVisible) ? 1 : 0; - if (command != null || CanNavigateBack && backButtonVisible) + if (command != null || (CanNavigateBack && backButtonVisible)) { _drawerToggle.DrawerIndicatorEnabled = false; diff --git a/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs b/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs index 50a7cb3f0e49..dd414a31c5e5 100644 --- a/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs +++ b/src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs @@ -180,7 +180,9 @@ void UpdateBackButton() // Set this before BackButtonVisible triggers an update to the handler // This way all useful information is present - // Show drawer toggle if it's a FlyoutPage, toolbar button should be shown, and either no pages are pushed or back button is not visible + // Show drawer toggle (hamburger icon) when FlyoutPage should display toolbar button + // and either we're on the root page or the back button is explicitly hidden. + // This ensures flyout access remains available even when back button is disabled (#21646) if (Parent is FlyoutPage flyout && flyout.ShouldShowToolbarButton() #if !WINDOWS // TODO NET 10 : Move this logic to ShouldShowToolbarButton && (!anyPagesPushed.Value || !BackButtonVisible) diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index 172a7dd15076..cf3f01f7023c 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -109,6 +109,8 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar } else { + // Reinitialize navigation icon to display flyout (hamburger) menu + // This ensures the icon is shown when back button is not visible nativeToolbar.NavigationIcon = new DrawerArrowDrawable(context!); if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) iconDrawable.Progress = 0; diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs index d3b5ce12bcd8..bc0a514d4bd9 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs @@ -3,7 +3,7 @@ namespace Maui.Controls.Sample.Issues { - [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a NavigationPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android)] + [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a NavigationPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android, issueTestNumber: 1)] public partial class Issue21646 : FlyoutPage { public Issue21646() diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs index b8c884d213a0..6f65bc4e3124 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs @@ -3,7 +3,7 @@ namespace Maui.Controls.Sample.Issues { - [Issue(IssueTracker.Github, 216468, "Flyout icon should remain visible when a page is pushed onto a ShellPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android)] + [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a ShellPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android, issueTestNumber: 2)] public class Issue21646_ShellFlyout : Shell { public Issue21646_ShellFlyout() From d96ce459209575a462c0947dd87c5f03c202e859 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Wed, 18 Feb 2026 12:06:09 +0530 Subject: [PATCH 11/14] Updated as per copilot concerns. --- .../tests/TestCases.HostApp/Issues/Issue21646.cs | 10 +++++----- .../TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs | 2 +- .../TestCases.Shared.Tests/Tests/Issues/Issue21646.cs | 8 +------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs index bc0a514d4bd9..937a8bd9fd83 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646.cs @@ -3,7 +3,7 @@ namespace Maui.Controls.Sample.Issues { - [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a NavigationPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android, issueTestNumber: 1)] + [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a NavigationPage with the back button disabled.", PlatformAffected.iOS | PlatformAffected.Android, issueTestNumber: 1)] public partial class Issue21646 : FlyoutPage { public Issue21646() @@ -11,7 +11,7 @@ public Issue21646() FlyoutLayoutBehavior = FlyoutLayoutBehavior.Popover; var flyoutPage = new ContentPage { - Title = "Menu", + Title = "OK", BackgroundColor = Colors.LightGray, Content = new StackLayout { @@ -43,11 +43,11 @@ public Issue21646() Detail = detailPage; } - private void OnNavigateButtonClicked(object sender, EventArgs e) + private async void OnNavigateButtonClicked(object sender, EventArgs e) { if (Detail is NavigationPage navPage) { - PushPageNoBackAsync(navPage, new ContentPage + await PushPageNoBackAsync(navPage, new ContentPage { Title = "Second Page", Content = new StackLayout @@ -58,7 +58,7 @@ private void OnNavigateButtonClicked(object sender, EventArgs e) } } - public static async void PushPageNoBackAsync(NavigationPage np, Page p) + public static async Task PushPageNoBackAsync(NavigationPage np, Page p) { NavigationPage.SetHasBackButton(p, false); await np.PushAsync(p); diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs index 6f65bc4e3124..abc8fb244e87 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue21646_ShellFlyout.cs @@ -3,7 +3,7 @@ namespace Maui.Controls.Sample.Issues { - [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a ShellPage with the back button disabled.", PlatformAffected.WPF | PlatformAffected.Android, issueTestNumber: 2)] + [Issue(IssueTracker.Github, 21646, "Flyout icon should remain visible when a page is pushed onto a ShellPage with the back button disabled.", PlatformAffected.iOS | PlatformAffected.Android, issueTestNumber: 2)] public class Issue21646_ShellFlyout : Shell { public Issue21646_ShellFlyout() diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs index 4099ab4d5712..63c873e826b6 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21646.cs @@ -6,12 +6,6 @@ namespace Microsoft.Maui.TestCases.Tests.Issues; public class Issue21646: _IssuesUITest { - -#if IOS || MACCATALYST - protected const string FlyoutIconId = "Menu"; -#else - protected const string FlyoutIconId = "OK"; -#endif public Issue21646(TestDevice testDevice) : base(testDevice) { } @@ -25,6 +19,6 @@ public void FlyoutIconShouldBeVisibleWithBackButtonDisabledInNavigationPage() App.WaitForElement("NavigateToNextPageButton"); App.Tap("NavigateToNextPageButton"); App.WaitForElement("SecondPageLabel"); - App.WaitForFlyoutIcon(FlyoutIconId, false); + App.WaitForFlyoutIcon(FlyoutIconAutomationId, false); } } \ No newline at end of file From f6314b556ed9ada7d488cb265d617be430da689f Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:28:00 -0600 Subject: [PATCH 12/14] [ci] Copilot CI: Android device tests, emulator robustness, and snapshot fallback (#34340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ## Description This PR brings together three improvements to the Copilot CI infrastructure: 1. **Auto-trigger uitests and device-tests on `darc-*` branches** — Major rework of `ci-copilot.yml` to support UI tests and device tests triggered on darc branches. 2. **Make emulator startup and provisioning more robust** — Improvements to `Start-Emulator.ps1` and `provision.yml` for more reliable Android emulator handling. 3. **Support fallback environment for snapshots** — Changes to `UITest.cs` and `VisualRegressionTester.cs` to support snapshot environment fallback. ## Changes - `eng/pipelines/ci-copilot.yml` — Reworked CI pipeline for Android device test support - `.github/scripts/shared/Start-Emulator.ps1` — More robust emulator startup - `eng/pipelines/common/provision.yml` — Provisioning improvements - `src/Controls/tests/TestCases.Shared.Tests/UITest.cs` — Snapshot fallback support - `src/TestUtils/src/VisualTestUtils/VisualRegressionTester.cs` — Snapshot fallback support --------- Co-authored-by: Jakub Florkowski Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/BuildAndRunHostApp.ps1 | 22 + .github/scripts/Review-PR.ps1 | 8 +- .github/scripts/shared/Build-AndDeploy.ps1 | 49 +- .github/scripts/shared/Start-Emulator.ps1 | 14 +- eng/pipelines/ci-copilot.yml | 701 +++++++++------------ 5 files changed, 368 insertions(+), 426 deletions(-) diff --git a/.github/scripts/BuildAndRunHostApp.ps1 b/.github/scripts/BuildAndRunHostApp.ps1 index 0702c73b015a..b349753980eb 100644 --- a/.github/scripts/BuildAndRunHostApp.ps1 +++ b/.github/scripts/BuildAndRunHostApp.ps1 @@ -232,6 +232,28 @@ if ($Category) { if ($Platform -eq "android") { Write-Info "Clearing Android logcat buffer before test..." & adb -s $DeviceUdid logcat -c + + # Dismiss any ANR dialogs that may have appeared during build/deploy. + # The emulator can sit idle during long builds, causing SystemUI ANR. + Write-Info "Dismissing any system dialogs before test..." + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_ENTER 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_BACK 2>$null + Start-Sleep -Seconds 1 + & adb -s $DeviceUdid shell input keyevent KEYCODE_WAKEUP 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_MENU 2>$null + Start-Sleep -Seconds 1 + + # Check for lingering ANR dialogs via window dump + $windowDump = & adb -s $DeviceUdid shell dumpsys window 2>$null | Select-String "Application Not Responding|ANR" + if ($windowDump) { + Write-Warn "ANR dialog detected — force-dismissing..." + & adb -s $DeviceUdid shell input keyevent KEYCODE_HOME 2>$null + Start-Sleep -Seconds 2 + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_BACK 2>$null + Start-Sleep -Seconds 1 + } } # Capture test start time for iOS logs diff --git a/.github/scripts/Review-PR.ps1 b/.github/scripts/Review-PR.ps1 index 9a83d73634b2..4b96d435f8c1 100644 --- a/.github/scripts/Review-PR.ps1 +++ b/.github/scripts/Review-PR.ps1 @@ -131,12 +131,14 @@ if (-not $ghVersion) { } Write-Host " ✅ GitHub CLI: $ghVersion" -ForegroundColor Green -# Check Copilot CLI -$copilotVersion = copilot --version 2>$null -if (-not $copilotVersion) { +# Check Copilot CLI - use Get-Command (reliable) then get version with merged streams +$copilotCmd = Get-Command copilot -ErrorAction SilentlyContinue +if (-not $copilotCmd) { Write-Error "Copilot CLI is not installed. Install with: npm install -g @github/copilot" exit 1 } +$copilotVersion = (& copilot --version 2>&1 | Out-String).Trim() +if (-not $copilotVersion) { $copilotVersion = $copilotCmd.Source } Write-Host " ✅ Copilot CLI: $copilotVersion" -ForegroundColor Green # Check PR exists diff --git a/.github/scripts/shared/Build-AndDeploy.ps1 b/.github/scripts/shared/Build-AndDeploy.ps1 index f57d5c088094..ae81e05a1ea8 100644 --- a/.github/scripts/shared/Build-AndDeploy.ps1 +++ b/.github/scripts/shared/Build-AndDeploy.ps1 @@ -85,19 +85,56 @@ if ($Platform -eq "android") { Write-Info "Build command: dotnet build $($buildArgs -join ' ')" $buildStartTime = Get-Date + $maxAttempts = 2 + $buildExitCode = 1 + + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + if ($attempt -gt 1) { + Write-Warn "Retrying build/deploy (attempt $attempt of $maxAttempts)..." + + # Uninstall any MAUI test packages to clear bad state + $installedPkg = & adb shell pm list packages 2>$null | Select-String "maui" | ForEach-Object { ($_ -replace "package:", "").Trim() } + if ($installedPkg) { + foreach ($pkg in $installedPkg) { + Write-Info "Uninstalling $pkg before retry..." + & adb uninstall $pkg 2>$null + } + } + + # Restart ADB server to recover from broken pipe / transient errors + Write-Info "Restarting ADB server..." + & adb kill-server 2>$null + Start-Sleep -Seconds 2 + & adb start-server + Start-Sleep -Seconds 2 + & adb wait-for-device + Start-Sleep -Seconds 3 + } + + & dotnet build @buildArgs + $buildExitCode = $LASTEXITCODE + + if ($buildExitCode -eq 0) { + break + } + + if ($attempt -lt $maxAttempts) { + Write-Warn "Build/deploy failed (attempt $attempt). ADB0010/broken-pipe errors are transient on API 30 — will retry." + } + } - # Build and deploy in one step (Run target handles both) - & dotnet build @buildArgs - - $buildExitCode = $LASTEXITCODE $buildDuration = (Get-Date) - $buildStartTime if ($buildExitCode -ne 0) { - Write-Error "Build/deploy failed with exit code $buildExitCode" + Write-Error "Build/deploy failed after $maxAttempts attempts with exit code $buildExitCode" exit $buildExitCode } - Write-Success "Build and deploy completed in $($buildDuration.TotalSeconds) seconds" + if ($attempt -gt 1) { + Write-Success "Build and deploy succeeded on attempt $attempt in $($buildDuration.TotalSeconds) seconds" + } else { + Write-Success "Build and deploy completed in $($buildDuration.TotalSeconds) seconds" + } #endregion diff --git a/.github/scripts/shared/Start-Emulator.ps1 b/.github/scripts/shared/Start-Emulator.ps1 index a5d90f192530..b1d7875326ba 100644 --- a/.github/scripts/shared/Start-Emulator.ps1 +++ b/.github/scripts/shared/Start-Emulator.ps1 @@ -65,7 +65,8 @@ if ($Platform -eq "android") { # Check if DeviceUdid is an AVD name (not an emulator-XXXX format) if ($DeviceUdid -and $DeviceUdid -notmatch "^emulator-\d+$") { # DeviceUdid is likely an AVD name - check if it's in the AVD list - $avdList = emulator -list-avds 2>$null + # Force array output - single AVD returns a string which breaks -contains + [string[]]$avdList = @(emulator -list-avds 2>$null) if ($avdList -contains $DeviceUdid) { Write-Info "DeviceUdid '$DeviceUdid' is an AVD name. Will boot this emulator..." $selectedAvd = $DeviceUdid @@ -103,7 +104,8 @@ if ($Platform -eq "android") { # Get list of available AVDs (if not already set from parameter) if (-not $selectedAvd) { - $avdList = emulator -list-avds 2>$null + # Force array output - single AVD returns a string which breaks indexing + [string[]]$avdList = @(emulator -list-avds 2>$null) if (-not $avdList -or $avdList.Count -eq 0) { Write-Error "No Android emulators found. Please create an Android Virtual Device (AVD) using Android Studio." @@ -119,7 +121,7 @@ if ($Platform -eq "android") { # Selection priority: # 1. API 34 device (matches CI provisioning) # 2. API 30 Nexus device - # 3. Any API 30 device + # 3. Any API 30 device (matches names like "Emulator_30", "API_30_xxx", etc.) # 4. Any Nexus device # 5. First available device @@ -132,16 +134,16 @@ if ($Platform -eq "android") { # Try to find API 30 Nexus device if (-not $selectedAvd) { - $api30Nexus = $avdList | Where-Object { $_ -match "API.*30" -and $_ -match "Nexus" } | Select-Object -First 1 + $api30Nexus = $avdList | Where-Object { $_ -match "30" -and $_ -match "Nexus" } | Select-Object -First 1 if ($api30Nexus) { $selectedAvd = $api30Nexus Write-Info "Selected API 30 Nexus device: $selectedAvd" } } - # Try to find any API 30 device + # Try to find any API 30 device (match "30" anywhere in name) if (-not $selectedAvd) { - $api30Device = $avdList | Where-Object { $_ -match "API.*30" } | Select-Object -First 1 + $api30Device = $avdList | Where-Object { $_ -match "30" } | Select-Object -First 1 if ($api30Device) { $selectedAvd = $api30Device Write-Info "Selected API 30 device: $selectedAvd" diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml index 05e4d11a2796..b8c8c4d4399f 100644 --- a/eng/pipelines/ci-copilot.yml +++ b/eng/pipelines/ci-copilot.yml @@ -26,7 +26,8 @@ parameters: - name: pool type: object default: - name: AcesShared + name: Azure Pipelines + vmImage: ubuntu-22.04 variables: - template: /eng/pipelines/common/variables.yml@self @@ -34,6 +35,10 @@ variables: value: false - name: Codeql.SkipTaskAutoInjection value: true + - name: APPIUM_HOME + value: $(System.DefaultWorkingDirectory)/.appium/ + - name: LogDirectory + value: $(Build.ArtifactStagingDirectory)/logs stages: - stage: ReviewPR @@ -42,7 +47,7 @@ stages: - job: CopilotReview displayName: 'Run Copilot PR Reviewer Agent' pool: ${{ parameters.pool }} - timeoutInMinutes: 180 + timeoutInMinutes: 360 steps: - checkout: self fetchDepth: 0 @@ -61,135 +66,24 @@ stages: echo "##vso[build.updatebuildnumber]PR ${{ parameters.PRNumber }} ${{ parameters.Platform }}" displayName: 'Set Pipeline Run Title' - # Provision environment (Xcode, .NET SDK, Android SDK, etc.) + # Enable KVM for Android emulator on Linux (same as ui-tests-steps.yml / device-tests-steps.yml) + - ${{ if eq(parameters.Platform, 'android') }}: + - template: common/enable-kvm.yml + + # Provision SDKs (same parameters as ui-tests-steps.yml) - template: common/provision.yml parameters: - skipXcode: false - skipProvisionator: false - skipAndroidCommonSdks: ${{ eq(parameters.Platform, 'ios') }} - skipAndroidPlatformApis: ${{ eq(parameters.Platform, 'ios') }} - skipJdk: ${{ eq(parameters.Platform, 'ios') }} - skipSimulatorSetup: false - skipCertificates: true - # Android emulator setup (skip for iOS) - skipAndroidEmulatorImages: ${{ eq(parameters.Platform, 'ios') }} - skipAndroidCreateAvds: ${{ eq(parameters.Platform, 'ios') }} + skipXcode: ${{ eq(parameters.Platform, 'android') }} + skipProvisionator: true + skipJdk: ${{ ne(parameters.Platform, 'android') }} + skipAndroidCommonSdks: ${{ ne(parameters.Platform, 'android') }} + skipAndroidPlatformApis: true + onlyAndroidPlatformDefaultApis: true + skipAndroidEmulatorImages: ${{ ne(parameters.Platform, 'android') }} + skipAndroidCreateAvds: true androidEmulatorApiLevel: '30' - - # Configure AVD for hardware acceleration (AcesShared ARM64 macOS agents have HVF) - # Match maui-pr Cake script: no GPU override on macOS (uses default hw GPU) - - script: | - echo "=== Configuring AVD for hardware-accelerated emulation ===" - AVD_CONFIG="$HOME/.android/avd/Emulator_30.avd/config.ini" - if [ -f "$AVD_CONFIG" ]; then - echo "Found AVD config: $AVD_CONFIG" - cat "$AVD_CONFIG" - else - echo "##vso[task.logissue type=warning]AVD config not found at: $AVD_CONFIG" - ls -la "$HOME/.android/avd/" 2>/dev/null || echo "AVD directory not found" - fi - displayName: 'Configure AVD' - condition: eq('${{ parameters.Platform }}', 'android') - - # Set up Android SDK PATH (required on self-hosted agents) - - pwsh: | - if ($env:ANDROID_SDK_ROOT) { - $platformTools = Join-Path $env:ANDROID_SDK_ROOT "platform-tools" - $emulatorPath = Join-Path $env:ANDROID_SDK_ROOT "emulator" - $cmdlineTools = Join-Path $env:ANDROID_SDK_ROOT "cmdline-tools/latest/bin" - - # Use Azure Pipelines' prependpath command to persist across steps - Write-Host "##vso[task.prependpath]$platformTools" - Write-Host "##vso[task.prependpath]$emulatorPath" - Write-Host "##vso[task.prependpath]$cmdlineTools" - - Write-Host "Added to PATH (will apply to subsequent steps):" - Write-Host " platform-tools: $platformTools" - Write-Host " emulator: $emulatorPath" - Write-Host " cmdline-tools: $cmdlineTools" - - # Verify the tools exist - if (Test-Path (Join-Path $platformTools "adb")) { - Write-Host "✅ adb found at: $platformTools/adb" - } else { - Write-Host "⚠️ adb NOT found at expected location" - } - if (Test-Path (Join-Path $emulatorPath "emulator")) { - Write-Host "✅ emulator found at: $emulatorPath/emulator" - } else { - Write-Host "⚠️ emulator NOT found at expected location" - } - } else { - Write-Host "##vso[task.logissue type=warning]ANDROID_SDK_ROOT not set, skipping PATH setup" - } - - # Auto-detect and set JAVA_HOME if not already set - if (-not $env:JAVA_HOME) { - $jvmDir = "/Library/Java/JavaVirtualMachines" - $msJdk = Get-ChildItem "$jvmDir/microsoft-*.jdk" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($msJdk) { - $javaHome = "$($msJdk.FullName)/Contents/Home" - Write-Host "##vso[task.setvariable variable=JAVA_HOME]$javaHome" - Write-Host "Set JAVA_HOME to: $javaHome" - } - } - displayName: 'Configure Android SDK PATH' - condition: eq('${{ parameters.Platform }}', 'android') - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) - - # Start Android Emulator EARLY (using Start-Emulator.ps1 script) - - pwsh: | - Write-Host "=== Starting Android Emulator ===" - Write-Host "Working directory: $(Get-Location)" - Write-Host "ANDROID_SDK_ROOT: $env:ANDROID_SDK_ROOT" - Write-Host "JAVA_HOME: $env:JAVA_HOME" - Write-Host "PATH (first 500 chars): $($env:PATH.Substring(0, [Math]::Min(500, $env:PATH.Length)))" - - # Verify adb is in path - $adbPath = Get-Command adb -ErrorAction SilentlyContinue - if ($adbPath) { - Write-Host "adb found at: $($adbPath.Source)" - } else { - Write-Host "adb NOT in PATH, checking manually..." - $manualAdb = Join-Path $env:ANDROID_SDK_ROOT "platform-tools/adb" - if (Test-Path $manualAdb) { - Write-Host "adb exists at $manualAdb but not in PATH" - } - } - - $scriptPath = ".github/scripts/shared/Start-Emulator.ps1" - Write-Host "Script path: $scriptPath" - - if (-not (Test-Path $scriptPath)) { - Write-Host "##vso[task.logissue type=error]Script not found: $scriptPath" - exit 1 - } - Write-Host "Script exists: OK" - - $ErrorActionPreference = "Continue" - try { - Write-Host "Invoking Start-Emulator.ps1 -Platform android -DeviceUdid Emulator_30..." - & "./$scriptPath" -Platform android -DeviceUdid Emulator_30 2>&1 | ForEach-Object { Write-Host $_ } - $exitCode = $LASTEXITCODE - Write-Host "Script returned exit code: $exitCode" - } catch { - Write-Host "##vso[task.logissue type=error]Exception: $_" - Write-Host $_.ScriptStackTrace - $exitCode = 1 - } - - if ($exitCode -ne 0) { - Write-Host "##vso[task.logissue type=error]Start-Emulator failed with code $exitCode" - exit $exitCode - } - - Write-Host "=== Android Emulator Started Successfully ===" - displayName: 'Start Android Emulator' - condition: eq('${{ parameters.Platform }}', 'android') - timeoutInMinutes: 35 - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) + skipSimulatorSetup: ${{ eq(parameters.Platform, 'android') }} + skipCertificates: true # Install .NET and workloads via build.ps1 - pwsh: ./build.ps1 --target=dotnet --configuration="Release" --verbosity=diagnostic @@ -210,214 +104,186 @@ stages: DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token) PRIVATE_BUILD: $(PrivateBuild) - # Verify environment is ready - - script: | - echo "=== Verifying Build Environment ===" - ERRORS="" - - # Check .NET SDK - echo "Checking .NET SDK..." - if ! dotnet --version; then - ERRORS="${ERRORS}\n- .NET SDK not available" - else - echo "✓ .NET SDK: $(dotnet --version)" - fi - - # Check Android SDK (only for Android platform) - if [ "${{ parameters.Platform }}" = "android" ]; then - echo "Checking Android SDK..." - if [ -z "$ANDROID_HOME" ] && [ -z "$ANDROID_SDK_ROOT" ]; then - ERRORS="${ERRORS}\n- ANDROID_HOME/ANDROID_SDK_ROOT not set" - else - SDK_PATH="${ANDROID_HOME:-$ANDROID_SDK_ROOT}" - if [ ! -d "$SDK_PATH" ]; then - ERRORS="${ERRORS}\n- Android SDK directory not found: $SDK_PATH" - else - echo "✓ Android SDK: $SDK_PATH" - fi - fi - fi - - # Check Xcode (macOS only) - if [ "$(uname)" = "Darwin" ]; then - echo "Checking Xcode..." - if ! xcodebuild -version; then - ERRORS="${ERRORS}\n- Xcode not available" - else - echo "✓ Xcode available" - fi + # Restore .NET tools (includes xharness) + - script: dotnet tool restore + displayName: 'Restore .NET Tools' + + # Create AVD and boot Android Emulator + - ${{ if eq(parameters.Platform, 'android') }}: + # Free disk space on hosted agents (emulator needs ~7GB for userdata partition) + - script: | + echo "=== Disk space before cleanup ===" + df -h /home + echo "Removing unnecessary tools to free space..." + sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /usr/local/share/chromium 2>/dev/null || true + sudo rm -rf /opt/hostedtoolcache/CodeQL /opt/hostedtoolcache/go /opt/hostedtoolcache/Python 2>/dev/null || true + sudo rm -rf /usr/share/swift 2>/dev/null || true + echo "=== Disk space after cleanup ===" + df -h /home + displayName: 'Free Disk Space for Emulator' + + - script: | + export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}" + export PATH="$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$PATH" + + echo "=== Creating AVD ===" + echo "no" | avdmanager create avd -n Emulator_30 -k "system-images;android-30;google_apis_playstore;x86_64" --device "Nexus 5X" --force - # Check iOS simulators - echo "Checking iOS simulators..." - if ! xcrun simctl list devices available | grep -q "iPhone"; then - ERRORS="${ERRORS}\n- No iOS simulators available" - else - echo "✓ iOS simulators available" + # Reduce userdata partition to fit on hosted agents (~4.2GB free) + AVD_CONFIG="$HOME/.android/avd/Emulator_30.avd/config.ini" + if [ -f "$AVD_CONFIG" ]; then + sed -i 's/disk.dataPartition.size=.*/disk.dataPartition.size=2048m/' "$AVD_CONFIG" + echo "Updated disk.dataPartition.size to 2048m" fi - fi - - # Check Java/JDK (only for Android platform) - if [ "${{ parameters.Platform }}" = "android" ]; then - echo "Checking JDK..." - if ! java -version 2>&1; then - ERRORS="${ERRORS}\n- JDK not available" - else - echo "✓ JDK available" + + # Pre-authorize ADB keys (mirrors android.cake HandleVirtualDevice) + echo "=== Pre-authorizing ADB keys ===" + mkdir -p "$HOME/.android" + if [ ! -f "$HOME/.android/adbkey" ]; then + adb keygen "$HOME/.android/adbkey" 2>/dev/null || true fi - fi - - # Report errors - if [ -n "$ERRORS" ]; then - echo "" - echo "##vso[task.logissue type=error]=== Environment Verification FAILED ===" - echo -e "Missing dependencies:$ERRORS" - echo "##vso[task.logissue type=error]Build environment is not properly configured. See above for details." - exit 1 - fi - - echo "" - echo "=== Environment Verification PASSED ===" - displayName: 'Verify Build Environment' + ADB_KEY_PUB="$HOME/.android/adbkey.pub" + AVD_DIR="$HOME/.android/avd/Emulator_30.avd" + if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then + cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub" + echo "ADB key pre-authorized for emulator" + fi + + echo "=== Starting Emulator ===" + # Kill any stale adb server and restart + adb kill-server 2>/dev/null || true + sleep 1 + adb start-server + + # Retry loop: emulator sometimes fails to connect ADB on first launch + MAX_LAUNCH_ATTEMPTS=2 + EMULATOR_PID="" + for LAUNCH_ATTEMPT in $(seq 1 $MAX_LAUNCH_ATTEMPTS); do + echo "--- Emulator launch attempt $LAUNCH_ATTEMPT of $MAX_LAUNCH_ATTEMPTS ---" + if [ $LAUNCH_ATTEMPT -gt 1 ]; then + echo "Cleaning up before retry..." + if [ -n "$EMULATOR_PID" ] && kill -0 "$EMULATOR_PID" 2>/dev/null; then + kill "$EMULATOR_PID" 2>/dev/null || true + sleep 2 + kill -0 "$EMULATOR_PID" 2>/dev/null && kill -9 "$EMULATOR_PID" 2>/dev/null || true + fi + sleep 3 + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + fi - # Restore .NET tools (includes xharness) - - script: | - echo "Restoring .NET tools..." - dotnet tool restore - echo "Tools restored successfully" - displayName: 'Restore .NET Tools' + nohup emulator -avd Emulator_30 -gpu swiftshader_indirect -no-window -no-snapshot -no-audio -no-boot-anim -partition-size 2048 > /tmp/emulator.log 2>&1 & + EMULATOR_PID=$! + echo "Emulator PID: $EMULATOR_PID" + + echo "Waiting for emulator device (adb wait-for-device, 120s timeout)..." + timeout 120 adb wait-for-device + if [ $? -eq 0 ]; then + echo "Device detected: $(adb devices -l | grep emulator)" + break + fi - # Boot Android emulator for UI tests - - script: | - echo "=== Booting Android Emulator ===" - - # Match maui-pr Cake script: on macOS use default GPU (hardware-accelerated), - # only use swiftshader on Linux. AcesShared agents are ARM64 macOS with HVF. - # Start emulator in background with logging for debugging - EMULATOR_LOG="/tmp/emulator-boot.log" - nohup $ANDROID_SDK_ROOT/emulator/emulator -avd Emulator_30 -no-window -no-snapshot -no-audio -no-boot-anim > "$EMULATOR_LOG" 2>&1 & - EMULATOR_PID=$! - echo "Emulator started with PID: $EMULATOR_PID" - - # Give emulator a moment to start and verify process is running - sleep 3 - if ! pgrep -f 'qemu-system' > /dev/null; then - echo "##vso[task.logissue type=error]Emulator process did not start" - echo "=== Emulator Log ===" - cat "$EMULATOR_LOG" 2>/dev/null || echo "No log available" - exit 1 - fi - echo "Emulator process verified running" - - # Wait for device to appear with timeout (don't use adb wait-for-device - can hang forever) - echo "Waiting for emulator device..." - device_timeout=60 - device_waited=0 - while ! adb devices | grep -q "emulator.*device"; do - sleep 2 - device_waited=$((device_waited + 2)) - if [ $device_waited -ge $device_timeout ]; then - echo "##vso[task.logissue type=error]Emulator device did not appear in time" - echo "=== Emulator Log (last 50 lines) ===" - tail -50 "$EMULATOR_LOG" 2>/dev/null || echo "No log available" + echo "##vso[task.logissue type=warning]adb wait-for-device timed out (attempt $LAUNCH_ATTEMPT)" adb devices -l - exit 1 - fi - done - echo "Emulator device detected" - - # Wait for boot_completed - echo "Waiting for emulator to finish booting..." - timeout=600 - waited=0 - while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do - sleep 2 - waited=$((waited + 2)) - echo "Waiting for boot... ($waited/$timeout seconds)" - if [ $waited -ge $timeout ]; then - echo "##vso[task.logissue type=error]Emulator did not boot in time" - echo "=== Emulator Log (last 50 lines) ===" - tail -50 "$EMULATOR_LOG" 2>/dev/null || echo "No log available" - adb devices -l - exit 1 - fi - done - echo "Boot completed flag set" - - # Wait for package manager service to be available (critical for app installation) - echo "Waiting for package manager service..." - pm_timeout=120 - pm_waited=0 - while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do - sleep 3 - pm_waited=$((pm_waited + 3)) - echo "Waiting for package manager... ($pm_waited/$pm_timeout seconds)" - if [ $pm_waited -ge $pm_timeout ]; then - echo "##vso[task.logissue type=error]Package manager service did not start" - echo "=== Checking services ===" - adb shell service list 2>/dev/null | head -20 || echo "Cannot list services" - exit 1 - fi - done - echo "Package manager service is ready" - - echo "=== Emulator booted successfully! ===" - adb devices -l - displayName: 'Boot Android Emulator' - condition: eq('${{ parameters.Platform }}', 'android') - continueOnError: true - timeoutInMinutes: 15 - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) - JAVA_HOME: $(JAVA_HOME_17_X64) + tail -30 /tmp/emulator.log - - script: | - echo "Installing Node.js 22..." - brew install node@22 - brew link --overwrite node@22 - if ! node --version; then - echo "##vso[task.logissue type=error]Failed to install Node.js" - exit 1 - fi - npm --version - echo "Node.js installed successfully" + if [ $LAUNCH_ATTEMPT -eq $MAX_LAUNCH_ATTEMPTS ]; then + echo "##vso[task.logissue type=error]Emulator failed to connect after $MAX_LAUNCH_ATTEMPTS attempts" + exit 1 + fi + done + + timeout=300 + waited=0 + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + sleep 5 + waited=$((waited + 5)) + if [ $waited -ge $timeout ]; then + echo "##vso[task.logissue type=error]Emulator did not finish booting after ${timeout}s" + exit 1 + fi + # At 90 seconds, restart ADB server to recover from auth issues (mirrors android.cake) + if [ $waited -eq 90 ]; then + echo "Boot taking longer than expected (90/${timeout}s). Restarting ADB server..." + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + echo "ADB server restarted. Continuing to wait..." + fi + # Re-ensure ADB keys every 60s during boot (mirrors android.cake PrepareDevice) + if [ $((waited % 60)) -eq 0 ] && [ $waited -gt 0 ]; then + if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then + cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub" 2>/dev/null || true + fi + fi + done + echo "Boot completed, waiting for package manager..." + + timeout=120 + waited=0 + while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do + sleep 5 + waited=$((waited + 5)) + if [ $waited -ge $timeout ]; then + echo "##vso[task.logissue type=error]Package manager not ready after ${timeout}s" + exit 1 + fi + done + + DEVICE_ID=$(adb devices | grep "emulator.*device" | awk '{print $1}') + echo "✅ Emulator fully booted: $DEVICE_ID" + + # Prepare emulator for CI use — keeps device responsive during idle period + echo "=== Preparing emulator for CI ===" + # Wait for device to stabilize after boot (transient offline state) + for i in $(seq 1 10); do + if adb -s $DEVICE_ID shell echo ok 2>/dev/null | grep -q ok; then + break + fi + echo "Device offline, retrying ($i/10)..." + sleep 3 + done + # Disable all animations (reduces CPU load and flakiness) + adb -s $DEVICE_ID shell settings put global window_animation_scale 0.0 + adb -s $DEVICE_ID shell settings put global transition_animation_scale 0.0 + adb -s $DEVICE_ID shell settings put global animator_duration_scale 0.0 + # Prevent screen from turning off (emulator simulates AC charging) + adb -s $DEVICE_ID shell settings put system screen_off_timeout 2147483647 + adb -s $DEVICE_ID shell svc power stayon true + # Wake screen and dismiss any lock screen + adb -s $DEVICE_ID shell input keyevent 82 + sleep 1 + # Dismiss any "System UI has stopped" or crash dialogs + adb -s $DEVICE_ID shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + # Clear logcat buffer so agent sees only fresh logs + adb -s $DEVICE_ID logcat -c 2>/dev/null || true + echo "Emulator preparation complete" + + echo "##vso[task.setvariable variable=DEVICE_UDID]$DEVICE_ID" + echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/platform-tools" + echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/emulator" + displayName: 'Create AVD and Boot Android Emulator' + retryCountOnTaskFailure: 1 + timeoutInMinutes: 15 + + # Install Node.js and Appium (same as ui-tests-steps.yml) + - task: UseNode@1 + inputs: + version: "24.x" displayName: 'Install Node.js' - - script: | - echo "Installing Appium and platform driver..." - - # Get npm global bin directory and add to PATH - NPM_BIN=$(npm config get prefix)/bin - echo "NPM global bin directory: $NPM_BIN" - export PATH="$NPM_BIN:$PATH" - - # Install Appium globally - npm install -g appium - - # Install both drivers — the agent may switch platforms if the bug - # only affects one platform (e.g., iOS bug triggered via Android run) - echo "Installing UiAutomator2 driver for Android..." - appium driver install uiautomator2 - echo "Installing XCUITest driver for iOS..." - appium driver install xcuitest - - # Verify installation - if ! which appium; then - echo "##vso[task.logissue type=error]Failed to install Appium" - exit 1 - fi - - APPIUM_PATH=$(which appium) - echo "Appium path: $APPIUM_PATH" - echo "Appium version: $(appium --version)" - echo "Appium drivers installed:" - appium driver list --installed - - # Export PATH for subsequent steps (Azure DevOps specific) - echo "##vso[task.prependpath]$NPM_BIN" - - echo "Appium installed successfully" + - pwsh: | + $skipAppiumDoctor = if ($IsMacOS -or $IsLinux) { "true" } else { "false" } + dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="$skipAppiumDoctor" -bl:"$(LogDirectory)/provision-appium.binlog" displayName: 'Install Appium' + retryCountOnTaskFailure: 2 + timeoutInMinutes: 10 + env: + APPIUM_HOME: $(APPIUM_HOME) - script: | echo "Installing GitHub CLI..." @@ -435,11 +301,18 @@ stages: echo "##vso[task.logissue type=error]GH_CLI_TOKEN is not set. Please configure the pipeline variable." exit 1 fi - echo "$(GH_CLI_TOKEN)" | gh auth login --with-token - if ! gh auth status; then - echo "##vso[task.logissue type=error]GitHub CLI authentication failed" - exit 1 + # Use GH_TOKEN env var to avoid scope validation issues with newer gh versions + export GH_TOKEN="$(GH_CLI_TOKEN)" + gh auth status + if [ $? -ne 0 ]; then + # Fallback: try direct login + echo "$(GH_CLI_TOKEN)" | gh auth login --with-token 2>/dev/null || true + if ! gh auth status; then + echo "##vso[task.logissue type=error]GitHub CLI authentication failed" + exit 1 + fi fi + echo "GitHub CLI authenticated successfully" displayName: 'Authenticate GitHub CLI' env: GH_CLI_TOKEN: $(GH_CLI_TOKEN) @@ -447,89 +320,14 @@ stages: - script: | echo "Installing GitHub Copilot CLI..." npm install -g @github/copilot - if ! which copilot; then - echo "##vso[task.logissue type=error]Failed to install GitHub Copilot CLI" - exit 1 - fi + # Ensure npm global bin is on PATH for subsequent steps (Linux UseNode installs to toolcache) + COPILOT_BIN_DIR=$(dirname "$(which copilot)") + echo "Copilot binary at: $COPILOT_BIN_DIR/copilot" + echo "##vso[task.prependpath]$COPILOT_BIN_DIR" + copilot --version || true echo "Copilot CLI installed successfully" displayName: 'Install GitHub Copilot CLI' - # Restart Android emulator to ensure it's fresh before PR Reviewer runs - # The emulator can become unstable after running for a long time - - script: | - echo "=== Restarting Android Emulator for PR Reviewer ===" - - # Kill any existing emulator - echo "Killing existing emulator..." - pkill -f 'qemu-system' 2>/dev/null || true - sleep 5 - - # Restart ADB server - echo "Restarting ADB server..." - adb kill-server - sleep 2 - adb start-server - sleep 2 - - # Start fresh emulator - EMULATOR_LOG="/tmp/emulator-fresh.log" - echo "Starting fresh emulator..." - nohup $ANDROID_SDK_ROOT/emulator/emulator -avd Emulator_30 -no-window -no-snapshot -no-audio -no-boot-anim > "$EMULATOR_LOG" 2>&1 & - EMULATOR_PID=$! - echo "Emulator started with PID: $EMULATOR_PID" - - # Wait for device to appear - echo "Waiting for emulator device..." - device_timeout=300 - device_waited=0 - while ! adb devices | grep -q "emulator.*device"; do - sleep 5 - device_waited=$((device_waited + 5)) - if [ $device_waited -ge $device_timeout ]; then - echo "##vso[task.logissue type=error]Emulator device did not appear in time" - cat "$EMULATOR_LOG" 2>/dev/null | tail -30 - exit 1 - fi - echo "Waiting for device... ($device_waited/$device_timeout seconds)" - done - echo "Emulator device detected" - - # Wait for boot_completed - echo "Waiting for boot_completed..." - boot_timeout=600 - boot_waited=0 - while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do - sleep 5 - boot_waited=$((boot_waited + 5)) - if [ $boot_waited -ge $boot_timeout ]; then - echo "##vso[task.logissue type=error]Emulator did not boot in time" - exit 1 - fi - done - echo "Boot completed" - - # Wait for package manager - echo "Waiting for package manager service..." - pm_timeout=120 - pm_waited=0 - while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do - sleep 5 - pm_waited=$((pm_waited + 5)) - if [ $pm_waited -ge $pm_timeout ]; then - echo "##vso[task.logissue type=error]Package manager service did not start" - adb shell service list 2>/dev/null | head -20 - exit 1 - fi - echo "Waiting for package manager... ($pm_waited/$pm_timeout seconds)" - done - - echo "=== Fresh Android Emulator Ready! ===" - adb devices -l - displayName: 'Restart Android Emulator (Fresh)' - condition: eq('${{ parameters.Platform }}', 'android') - timeoutInMinutes: 15 - env: - ANDROID_SDK_ROOT: $(ANDROID_SDK_ROOT) # Boot iOS Simulator (only for iOS platform) # UI test baseline screenshots are captured on iPhone Xs - must use same device @@ -623,10 +421,86 @@ stages: condition: eq('${{ parameters.Platform }}', 'ios') timeoutInMinutes: 5 + # Warm up the emulator right before the agent runs. + # The emulator may have been idle for 15-30 min while Appium/Node/CLI were installed. + # Without this, SystemUI can ANR when the agent first touches it. + - script: | + set -e + DEVICE_ID="$(DEVICE_UDID)" + if [ -z "$DEVICE_ID" ]; then + echo "No DEVICE_UDID set — skipping warmup" + exit 0 + fi + + echo "=== Emulator warmup before agent ===" + # Verify device is still connected + if ! adb -s "$DEVICE_ID" shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then + echo "Device not responding. Restarting ADB server..." + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + timeout 60 adb wait-for-device + fi + + # Dismiss ANR dialogs and wake screen — run twice for reliability + for PASS in 1 2; do + echo "--- Warmup pass $PASS ---" + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_WAKEUP 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_MENU 2>/dev/null || true + sleep 1 + + # Dismiss system dialogs (ANR, crash, etc.) + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_ENTER 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + sleep 1 + done + + # Check for lingering ANR in window state + if adb -s "$DEVICE_ID" shell dumpsys window 2>/dev/null | grep -qi "Application Not Responding\|ANR"; then + echo "⚠️ ANR dialog still present — force-dismissing with HOME + BACK" + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_HOME 2>/dev/null || true + sleep 2 + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + sleep 1 + fi + + # Open and close Settings to exercise the system and confirm responsiveness + adb -s "$DEVICE_ID" shell am start -a android.settings.SETTINGS 2>/dev/null || true + sleep 3 + adb -s "$DEVICE_ID" shell am force-stop com.android.settings 2>/dev/null || true + + # Final dialog sweep + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + + # Clear logcat so agent gets clean logs + adb -s "$DEVICE_ID" logcat -c 2>/dev/null || true + + echo "✅ Emulator warmed up and responsive" + displayName: 'Warm Up Android Emulator' + condition: and(succeeded(), eq('${{ parameters.Platform }}', 'android')) + timeoutInMinutes: 3 + - script: | echo "Running Copilot PR Reviewer Agent via Review-PR.ps1..." echo "Reviewing PR #${{ parameters.PRNumber }}..." + # Ensure copilot CLI is accessible to pwsh subprocess. + # npm global install on Linux goes to UseNode@1 toolcache path which may not + # be on PATH inside pwsh even when exported from bash. Create a symlink in + # /usr/local/bin which is universally on PATH for all shells. + COPILOT_PATH=$(which copilot 2>/dev/null || find /opt/hostedtoolcache/node -name copilot -type f 2>/dev/null | head -1) + if [ -n "$COPILOT_PATH" ] && [ ! -f /usr/local/bin/copilot ]; then + sudo ln -sf "$COPILOT_PATH" /usr/local/bin/copilot + echo "Symlinked copilot to /usr/local/bin/copilot" + fi + echo "copilot location: $(which copilot 2>/dev/null || echo 'not found')" + # Verify pwsh can find it + pwsh -NoProfile -c 'Write-Host "pwsh sees copilot at: $(Get-Command copilot -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)"' + # Configure git identity (required for merge operations on self-hosted agents) git config user.email "copilot-ci@microsoft.com" git config user.name "Copilot CI" @@ -636,7 +510,12 @@ stages: # AcesShared agents may have a newer Xcode than the .NET iOS SDK expects cp Directory.Build.Override.props.in Directory.Build.Override.props # Insert ValidateXcodeVersion before closing tag - sed -i '' 's|| false\n|' Directory.Build.Override.props + # GNU sed (Linux) uses -i without suffix; BSD sed (macOS) uses -i '' + if [[ "$(uname)" == "Linux" ]]; then + sed -i 's|| false\n|' Directory.Build.Override.props + else + sed -i '' 's|| false\n|' Directory.Build.Override.props + fi # Create artifacts directory for Copilot outputs mkdir -p $(Build.ArtifactStagingDirectory)/copilot-logs @@ -645,7 +524,7 @@ stages: # The script will merge the PR into the current branch # -PostSummaryComment and -RunFinalize handle posting comments set +e - pwsh .github/scripts/Review-PR.ps1 -PRNumber ${{ parameters.PRNumber }} -Platform ${{ parameters.Platform }} -RunFinalize -PostSummaryComment -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md" + pwsh -NoProfile .github/scripts/Review-PR.ps1 -PRNumber ${{ parameters.PRNumber }} -Platform ${{ parameters.Platform }} -RunFinalize -PostSummaryComment -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md" COPILOT_EXIT_CODE=$? set -e From e10b0bee7687274ebc3e8ec9f71eb2a4b068dbf7 Mon Sep 17 00:00:00 2001 From: Stephane Delcroix Date: Fri, 13 Mar 2026 10:26:37 +0100 Subject: [PATCH 13/14] [Regression] Fix TypedBinding nested property re-subscription (#34428) (#34449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ## Description Cherry-pick of [`5d6e5a20`](https://github.com/dotnet/maui/commit/5d6e5a20fa796332e4651041bdfeb704bcdc062f) from `net11.0`, adapted for `main`. Fixes #34428 ### Problem Release 10.0.50 introduced a performance optimization (`_isSubscribed` flag) in `TypedBinding` that prevented re-subscribing to intermediate INPC objects when they changed. This caused compiled bindings with nested property paths (e.g. `{Binding ViewModel.Text}`) to stop updating when the intermediate object was replaced. ### Fix Remove the `_isSubscribed` guard and always call `Subscribe()` on every `Apply`. The `Subscribe()` implementation is already idempotent — it diffs old vs new subscription targets — so calling it repeatedly is safe with minimal overhead. ### Tests Two regression tests added to `TypedBindingUnitTests.cs`: - `TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull` - `TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced` Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Controls/src/Core/TypedBinding.cs | 9 +-- .../Core.UnitTests/TypedBindingUnitTests.cs | 81 +++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/TypedBinding.cs b/src/Controls/src/Core/TypedBinding.cs index 90b1a1d9f30e..4a5ad5545fb2 100644 --- a/src/Controls/src/Core/TypedBinding.cs +++ b/src/Controls/src/Core/TypedBinding.cs @@ -147,7 +147,6 @@ public TypedBinding(Func getter, Actio List> _ancestryChain; bool _isBindingContextRelativeSource; BindingMode _cachedMode; - bool _isSubscribed; bool _isTSource; // cached type check result object _cachedDefaultValue; // cached default value bool _hasDefaultValue; @@ -289,7 +288,6 @@ internal override void Unapply(bool fromBindingContextChanged = false) if (_handlers != null) Unsubscribe(); - _isSubscribed = false; _cachedMode = BindingMode.Default; _hasDefaultValue = false; _cachedDefaultValue = null; @@ -332,11 +330,12 @@ internal void ApplyCore(object sourceObject, BindableObject target, BindableProp var needsGetter = (mode == BindingMode.TwoWay && !fromTarget) || mode == BindingMode.OneWay || mode == BindingMode.OneTime; - // Only subscribe once per binding lifetime - if (!_isSubscribed && isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null) + // Subscribe on every Apply so that intermediate objects that changed are re-subscribed. + // Subscribe() is idempotent: it diffs old vs new subscription targets and only + // updates what changed, so calling this repeatedly is safe. + if (isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null) { Subscribe((TSource)sourceObject); - _isSubscribed = true; } if (needsGetter) diff --git a/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs b/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs index 5fbf2f11f096..e8ceee33570a 100644 --- a/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs @@ -1773,6 +1773,87 @@ public string Title } } + [Fact] + //https://github.com/dotnet/maui/issues/34428 + public void TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull() + { + // Regression: when an intermediate object in the path starts as null and later becomes + // non-null, the binding must re-establish subscriptions to nested properties. + // Previously, the _isSubscribed flag prevented re-subscribing after the first Apply. + + var vm = new ComplexMockViewModel + { + Model = null // Start with null intermediate + }; + + var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null); + + var binding = new TypedBinding( + cvm => cvm.Model is { } m ? (m.Text, true) : (null, false), + (cvm, t) => { if (cvm.Model is { } m) m.Text = t; }, + new[] { + new Tuple, string>(cvm => cvm, "Model"), + new Tuple, string>(cvm => cvm.Model, "Text") + }) + { Mode = BindingMode.OneWay }; + + var bindable = new MockBindable(); + bindable.SetBinding(property, binding); + bindable.BindingContext = vm; + + // Initially null model → binding returns null/default + Assert.Null(bindable.GetValue(property)); + + // Set Model to non-null → binding should pick up the value + vm.Model = new ComplexMockViewModel { Text = "Initial" }; + Assert.Equal("Initial", (string)bindable.GetValue(property)); + + // Change nested property → binding MUST update (this was the regression) + vm.Model.Text = "Updated"; + Assert.Equal("Updated", (string)bindable.GetValue(property)); + } + + [Fact] + //https://github.com/dotnet/maui/issues/34428 + public void TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced() + { + // When the intermediate object is replaced (non-null → different non-null object), + // the binding must switch subscriptions to the new object. + + var child1 = new ComplexMockViewModel { Text = "Child1" }; + var child2 = new ComplexMockViewModel { Text = "Child2" }; + var vm = new ComplexMockViewModel { Model = child1 }; + + var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null); + + var binding = new TypedBinding( + cvm => cvm.Model is { } m ? (m.Text, true) : (null, false), + (cvm, t) => { if (cvm.Model is { } m) m.Text = t; }, + new[] { + new Tuple, string>(cvm => cvm, "Model"), + new Tuple, string>(cvm => cvm.Model, "Text") + }) + { Mode = BindingMode.OneWay }; + + var bindable = new MockBindable(); + bindable.SetBinding(property, binding); + bindable.BindingContext = vm; + + Assert.Equal("Child1", (string)bindable.GetValue(property)); + + // Replace intermediate with a different object + vm.Model = child2; + Assert.Equal("Child2", (string)bindable.GetValue(property)); + + // Changing the OLD intermediate should NOT fire the binding + child1.Text = "OldChildChanged"; + Assert.Equal("Child2", (string)bindable.GetValue(property)); + + // Changing the NEW intermediate SHOULD fire the binding + child2.Text = "Child2Updated"; + Assert.Equal("Child2Updated", (string)bindable.GetValue(property)); + } + [Fact] //https://github.com/xamarin/Microsoft.Maui.Controls/issues/3650 //https://github.com/xamarin/Microsoft.Maui.Controls/issues/3613 From e2f570fe591ee347f146b099ab61f912cc9bc492 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:45:53 -0500 Subject: [PATCH 14/14] CI Investigation: adopt dotnet/arcade-skills plugin + thin MAUI context skill (#34438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ## Summary This PR adopts the [`dotnet/arcade-skills`](https://github.com/dotnet/arcade-skills) plugin system for CI investigation in dotnet/maui, replacing the need for custom in-repo PowerShell scripts. Two files are added: 1. **`.github/copilot/settings.json`** — repo-level plugin declaration that auto-installs the `dotnet-dnceng` plugin for all users 2. **`.github/skills/azdo-build-investigator/SKILL.md`** — thin MAUI-specific context supplement (~60 lines, no scripts) --- ## Background & Motivation ### The Problem When investigating CI failures on dotnet/maui PRs, contributors and AI agents need to: - Query Azure DevOps builds across 3 pipelines (`maui-pr`, `maui-pr-devicetests`, `maui-pr-uitests`) - Dig into Helix test logs for device test failures - Analyze MSBuild binlogs for obscure build failures - Detect hidden test failures caused by XHarness exiting with code 0 even when tests fail PR #34335 (`feature/azdo-ci-instructions`) addressed this with ~700 lines of custom PowerShell scripts. During review of that PR, we discovered [`dotnet/arcade-skills`](https://github.com/dotnet/arcade-skills) — a .NET engineering-maintained plugin that provides native MCP tooling for exactly this problem space, and already lists `dotnet/maui` as a supported repository. ### Why arcade-skills Instead of Custom Scripts The `dotnet-dnceng` plugin in arcade-skills provides: | MCP Server | Tool | Replaces | |------------|------|---------| | `ado-dnceng-public` | Native ADO queries via `@azure-devops/mcp` | `Get-BuildInfo.ps1`, `Get-BuildErrors.ps1`, `Get-PrBuildIds.ps1` | | `hlx` | Helix test infrastructure via `lewing.helix.mcp` | `Get-HelixLogs.ps1` | | `mcp-binlog-tool` | MSBuild binlog analysis via `baronfel.binlog.mcp` | `Get-BuildBinlogs.ps1` + manual `binlogtool` | MCP tools are first-class AI primitives — the AI calls them directly with structured parameters rather than running shell scripts and parsing text output. This is more reliable and maintainable. ### Auto-Loading (No User Action Required) The key mechanism that makes this work seamlessly: **`.github/copilot/settings.json`** supports `enabledPlugins` (introduced in Copilot CLI v0.0.422) — a "declarative plugin auto-install" that runs at session startup when a user opens this repository. Every user who opens dotnet/maui gets the `dotnet-dnceng` plugin with all its MCP servers automatically. No `/plugin install` command needed. ```json { "extraKnownMarketplaces": [ { "url": "https://github.com/dotnet/arcade-skills" } ], "enabledPlugins": ["dotnet-dnceng@dotnet-arcade-skills"] } ``` --- ## What the MAUI Context Skill Adds The arcade-skills `ci-analysis` skill is excellent but contains outdated MAUI-specific information (it lists `maui-public` as the pipeline name, which is wrong). The thin `azdo-build-investigator/SKILL.md` provides corrections and MAUI-specific domain knowledge: ### Correct Pipeline Names/IDs | Pipeline | Definition ID | Purpose | |----------|--------------|---------| | `maui-pr` | **302** | Main build — check first | | `maui-pr-devicetests` | **314** | Helix device tests | | `maui-pr-uitests` | **313** | Appium UI tests | ### XHarness Exit-0 Blind Spot XHarness (used in `maui-pr-devicetests`) **exits with code 0 even when tests fail**. The ADO job shows ✅ "Succeeded" while actual test failures hide inside Helix work items. The SKILL.md documents how to detect this via the Helix `ResultSummaryByBuild` API. This quirk was discovered while investigating PRs with the `s/agent-gate-failed` label where CI appeared green but tests were actually failing. ### Container Artifact Quirk for Binlogs MAUI build artifacts are **Container type** (not `PipelineArtifact`), so standard `az pipelines runs artifact download` does not work for binlogs. The SKILL.md documents the correct download approach using the ADO File Container API with Bearer auth. --- ## Relationship to PR #34335 PR #34335 (`feature/azdo-ci-instructions`) adds the same investigation capability via 5 custom PowerShell scripts. This PR supersedes that approach. The knowledge gained building those scripts (XHarness exit-0 discovery, Container artifact API approach, pipeline IDs) is preserved in the SKILL.md here. We recommend closing #34335 in favor of this approach, which: - Has ~5% of the code to maintain - Uses MCP tooling that will improve over time as arcade-skills evolves - Auto-loads for all contributors without any setup --- ## Files Changed ``` .github/copilot/settings.json (new) — repo-level plugin auto-install .github/skills/azdo-build-investigator/SKILL.md (new) — MAUI-specific CI context ``` ## Testing - Verified `.github/copilot/settings.json` schema matches Copilot CLI v0.0.422+ `enabledPlugins` format - Verified `dotnet-dnceng@dotnet-arcade-skills` resolves against the marketplace at `https://github.com/dotnet/arcade-skills/.github/plugin/marketplace.json` - SKILL.md pipeline IDs verified against live ADO builds: maui-pr=302, maui-pr-devicetests=314, maui-pr-uitests=313 /cc @PureWeen --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/README-AI.md | 4 +- .github/copilot-instructions.md | 5 - .github/copilot/settings.json | 13 + .../skills/azdo-build-investigator/SKILL.md | 71 +++++ .github/skills/pr-build-status/SKILL.md | 114 -------- .../scripts/Get-BuildErrors.ps1 | 208 --------------- .../pr-build-status/scripts/Get-BuildInfo.ps1 | 104 -------- .../pr-build-status/scripts/Get-HelixLogs.ps1 | 247 ------------------ .../scripts/Get-PrBuildIds.ps1 | 65 ----- .github/skills/pr-finalize/SKILL.md | 2 +- 10 files changed, 87 insertions(+), 746 deletions(-) create mode 100644 .github/copilot/settings.json create mode 100644 .github/skills/azdo-build-investigator/SKILL.md delete mode 100644 .github/skills/pr-build-status/SKILL.md delete mode 100644 .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 delete mode 100644 .github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 delete mode 100644 .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 delete mode 100644 .github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 diff --git a/.github/README-AI.md b/.github/README-AI.md index c703358dfade..843637a2988c 100644 --- a/.github/README-AI.md +++ b/.github/README-AI.md @@ -250,7 +250,7 @@ Reusable skills in `.github/skills/` that agents can invoke: - **`verify-tests-fail-without-fix/`** - Verifies UI tests catch bugs (auto-detects mode based on git diff) - **`write-ui-tests/`** - Creates UI tests for issues following MAUI conventions - **`write-xaml-tests/`** - Creates XAML unit tests for parsing, XamlC, and source generation issues -- **`pr-build-status/`** - Retrieves Azure DevOps build status for PRs +- **`azdo-build-investigator/`** - MAUI-specific CI investigation context (works with `ci-analysis` from arcade-skills plugin) ### Recent Improvements (January 2026) @@ -365,7 +365,7 @@ For issues or questions about the AI agent instructions: **Agent Files**: - 4 agent files (pr.md, pr/post-gate.md, sandbox-agent.md, write-tests-agent.md) -- 5 skills (try-fix, verify-tests-fail-without-fix, write-ui-tests, write-xaml-tests, pr-build-status) +- 5 skills (try-fix, verify-tests-fail-without-fix, write-ui-tests, write-xaml-tests, azdo-build-investigator) - All validated and consistent with consolidated structure **Automation**: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d0d99c2b8373..fb99bc2f87f5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -312,11 +312,6 @@ Skills are modular capabilities that can be invoked directly or used by agents. - **Two modes**: Verify failure only (test creation) or full verification (test + fix) - **Used by**: After creating tests, before considering PR complete -8. **pr-build-status** (`.github/skills/pr-build-status/SKILL.md`) - - **Purpose**: Retrieves Azure DevOps build information for PRs (build IDs, stage status, failed jobs) - - **Trigger phrases**: "check build for PR #XXXXX", "why did PR build fail", "get build status" - - **Used by**: When investigating CI failures - 8. **run-integration-tests** (`.github/skills/run-integration-tests/SKILL.md`) - **Purpose**: Build, pack, and run .NET MAUI integration tests locally - **Trigger phrases**: "run integration tests", "test templates locally", "run macOSTemplates tests", "run RunOniOS tests" diff --git a/.github/copilot/settings.json b/.github/copilot/settings.json new file mode 100644 index 000000000000..35358294c16e --- /dev/null +++ b/.github/copilot/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "dotnet-arcade-skills": { + "source": { + "source": "github", + "repo": "dotnet/arcade-skills" + } + } + }, + "enabledPlugins": { + "dotnet-dnceng@dotnet-arcade-skills": true + } +} diff --git a/.github/skills/azdo-build-investigator/SKILL.md b/.github/skills/azdo-build-investigator/SKILL.md new file mode 100644 index 000000000000..bd3ddc905332 --- /dev/null +++ b/.github/skills/azdo-build-investigator/SKILL.md @@ -0,0 +1,71 @@ +--- +name: azdo-build-investigator +description: "Investigate CI failures for dotnet/maui PRs — build errors, Helix test logs, and binlog analysis. Use when asked about failing checks, CI status, test failures, 'why is CI red', 'build failed', 'what's failing on PR', Helix failures, or device test failures." +metadata: + author: dotnet-maui + version: "2.0" +--- + +# dotnet/maui CI Investigation Context + +This skill provides MAUI-specific context for CI investigation. Use it together with the `ci-analysis` skill (loaded from the `dotnet-dnceng@dotnet-arcade-skills` plugin via `.github/copilot/settings.json`). + +> **First**: invoke the `ci-analysis` skill — it handles the core investigation workflow using `Get-CIStatus.ps1` and `gh` CLI (with MCP tools as optional enhancements if available). This skill provides MAUI-specific corrections and context on top of that. + +## Script Location + +The `ci-analysis` skill and its `Get-CIStatus.ps1` script are loaded automatically from the `dotnet/arcade-skills` plugin (configured in `.github/copilot/settings.json` via `enabledPlugins`). The CLI caches scripts to `~/.copilot/installed-plugins/dotnet-arcade-skills/`. No manual download is needed. + +## MAUI CI Pipelines + +> ⚠️ The `ci-analysis` skill's reference doc lists `maui-public` as the MAUI pipeline — **this is outdated**. The correct pipeline names are below. + +| Pipeline Name | Definition ID | Purpose | +|---------------|---------------|---------| +| `maui-pr` | **302** | Main build — check this first | +| `maui-pr-devicetests` | **314** | Helix device tests (iOS, Android, Windows, MacCatalyst) | +| `maui-pr-uitests` | **313** | Appium-based UI tests | + +**Organization**: `dnceng-public` / project `public` + +**Investigation priority order**: `maui-pr` → `maui-pr-devicetests` → `maui-pr-uitests` + +Most failures are in `maui-pr`. Device test failures appear in `maui-pr-devicetests`. Focus on the first failing pipeline before checking others. + +## MAUI-Specific Quirks + +### XHarness Exit-0 Blind Spot + +XHarness (used for iOS/Android device tests in `maui-pr-devicetests`) **exits with code 0 even when tests fail**. This means: +- The ADO job shows ✅ "Succeeded" +- `ci-analysis` may report no failures +- But actual test failures are hidden inside the Helix work items + +**How to detect hidden test failures**: Query the `ResultSummaryByBuild` Helix API endpoint: +``` +GET https://helix.dot.net/api/2019-06-17/jobs/{correlationId}/aggregated +``` +Look for `Failed` > 0 in the response even when the ADO build job shows green. + +When `ci-analysis` reports a `maui-pr-devicetests` build as passing but the PR has a `s/agent-gate-failed` label or the user suspects device test failures, always cross-check Helix `ResultSummaryByBuild`. + +### Container Artifact Binlogs + +MAUI build artifacts are **Container type**, not `PipelineArtifact`. This means: +- `az pipelines runs artifact download` does **not** work for binlogs +- Artifact names are like `Windows_NT_Build Windows (Debug)_Attempt1` (not `binlog`) +- Download requires a Bearer token from `az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798` +- Use the ADO File Container API: `/_apis/resources/Containers/{id}?api-version=5.0-preview&$format=OctetStream` + +If available, use the `mcp-binlog-tool` MCP server to analyze downloaded `.binlog` files. This is optional — the core investigation workflow works without it via `gh` CLI and REST APIs. + +## Common MAUI Failure Patterns + +| Pattern | Where | Notes | +|---------|-------|-------| +| `error CS####` | `maui-pr` | C# compiler error — check file/line | +| `error XA####` | `maui-pr` | Android build error | +| `XamlC` | `maui-pr` | XAML compiler — usually missing type or bad binding | +| `XHarness timeout` | `maui-pr-devicetests` Helix logs | Test killed by infrastructure; may be transient | +| `No test result files found` | `maui-pr-devicetests` Helix logs | Tests never ran or app crashed on launch | +| UI test screenshot diff | `maui-pr-uitests` | Visual regression; check baseline images | diff --git a/.github/skills/pr-build-status/SKILL.md b/.github/skills/pr-build-status/SKILL.md deleted file mode 100644 index c4a542b8b9a3..000000000000 --- a/.github/skills/pr-build-status/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: pr-build-status -description: "Retrieve Azure DevOps build information for GitHub Pull Requests, including build IDs, stage status, failed jobs, and Helix console logs for any Helix-based test failures." -metadata: - author: dotnet-maui - version: "1.1" -compatibility: Requires GitHub CLI (gh) authenticated with access to dotnet/maui repository. ---- - -# PR Build Status Skill - -Retrieve Azure DevOps build information for GitHub Pull Requests, including Helix test logs. - -## Tools Required - -This skill uses `bash` together with `pwsh` (PowerShell 7+) to run the PowerShell scripts. No file editing or other tools are required. - -## When to Use - -- User asks about CI/CD status for a PR -- User asks about failed checks or builds -- User asks "what's failing on PR #XXXXX" -- User wants to see test results -- **User asks about Helix failures (device tests, integration tests, etc.)** -- **User needs to debug why tests are failing on Helix infrastructure** - -## Scripts - -All scripts are in `.github/skills/pr-build-status/scripts/` - -### 1. Get Build IDs for a PR -```bash -pwsh .github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 -PrNumber -``` - -### 2. Get Build Status -```bash -pwsh .github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 -BuildId -# For failed jobs only: -pwsh .github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 -BuildId -FailedOnly -``` - -### 3. Get Build Errors and Test Failures -```bash -# Get all errors (build errors + test failures) -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId - -# Get only build/compilation errors -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId -ErrorsOnly - -# Get only test failures -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId -TestsOnly -``` - -### 4. Get Helix Console Logs -```bash -# List all Helix work items and their status -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId - -# Filter by platform -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -Platform Windows - -# Show console log content for failed work items -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -ShowConsoleLog - -# Filter by work item name and show more log lines -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -WorkItem "*Lifecycle*" -ShowConsoleLog -TailLines 200 -``` - -## Workflow - -### Standard Build Failures -1. Get build IDs: `Get-PrBuildIds.ps1 -PrNumber XXXXX` -2. For each build, get status: `Get-BuildInfo.ps1 -BuildId YYYYY -FailedOnly` -3. For failed builds, get errors: `Get-BuildErrors.ps1 -BuildId YYYYY` - -### Helix Test Failures -1. Get build IDs: `Get-PrBuildIds.ps1 -PrNumber XXXXX` -2. Find the build with Helix jobs (e.g., `maui-pr-devicetests`, `maui-integration-tests`) -3. Get Helix logs: `Get-HelixLogs.ps1 -BuildId YYYYY -ShowConsoleLog` -4. For specific platform: `Get-HelixLogs.ps1 -BuildId YYYYY -Platform Windows -ShowConsoleLog` - -## Understanding Helix Logs - -Helix is the .NET engineering infrastructure that runs tests across multiple platforms and device types. Tests that run on Helix include: -- **Device tests** - Run on real devices/emulators (iOS, Android, Windows, MacCatalyst) -- **Integration tests** - Run on various OS configurations -- **Other distributed tests** - Any test scenario that requires Helix infrastructure - -When Helix tests fail: - -1. **Build stage** - Compiles and packages the test app/harness -2. **Helix submission** - Sends the work items to Helix queues -3. **Work item execution** - Helix runs the tests on target machines/devices -4. **Console log** - Contains stdout/stderr from the test execution - -The `Get-HelixLogs.ps1` script retrieves the console logs which show: -- Test execution output -- Any crashes or errors -- Infrastructure issues (timeouts, installation failures, etc.) - -## Common Helix Failure Patterns - -| Pattern in Console Log | Meaning | -|------------------------|---------| -| "XHarness timeout" | Test took too long, killed by infrastructure | -| "No test result files found" | Tests never ran or process crashed | -| "error MT..." or "error BL..." | Build/linking error (check build logs instead) | -| Exit code non-zero | Test failures or infrastructure issues | - -## Prerequisites - -- `gh` (GitHub CLI) - authenticated -- `pwsh` (PowerShell 7+) diff --git a/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 b/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 deleted file mode 100644 index 2ad34c5e521e..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 +++ /dev/null @@ -1,208 +0,0 @@ -<# -.SYNOPSIS - Retrieves build errors and test failures from an Azure DevOps build. - -.DESCRIPTION - Queries the Azure DevOps build timeline to find failed jobs and tasks, - then extracts build errors (MSBuild errors, compilation failures) and - test failures with their details. - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER TestsOnly - If specified, only returns test results (no build errors). - -.PARAMETER ErrorsOnly - If specified, only returns build errors (no test results). - -.PARAMETER JobFilter - Optional filter to match job/task names (supports wildcards). - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 -ErrorsOnly - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 -TestsOnly -JobFilter "*SafeArea*" - -.OUTPUTS - Objects with Type (BuildError/TestFailure), Source, Message, and Details properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [switch]$TestsOnly, - - [Parameter(Mandatory = $false)] - [switch]$ErrorsOnly, - - [Parameter(Mandatory = $false)] - [string]$JobFilter -) - -$ErrorActionPreference = "Stop" - -# Get build timeline -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}/timeline?api-version=7.0" - -try { - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps timeline API: $_" - exit 1 -} - -$allResults = @() - -# --- SECTION 1: Find Build Errors from Failed Tasks --- -if (-not $TestsOnly) { - $failedTasks = $timeline.records | Where-Object { - $_.type -eq "Task" -and - $_.result -eq "failed" -and - $_.log.url -and - (-not $JobFilter -or $_.name -like $JobFilter) - } - - foreach ($task in $failedTasks) { - Write-Host "Analyzing failed task: $($task.name)" -ForegroundColor Red - - try { - $log = Invoke-RestMethod -Uri $task.log.url -Method Get - $lines = $log -split "`n" - - # Find MSBuild errors and ##[error] markers - $errorLines = $lines | Where-Object { - $_ -match ": error [A-Z]+\d*:" -or # MSBuild errors (CS1234, MT1234, etc.) - $_ -match ": Error :" -or # Xamarin.Shared.Sdk errors - $_ -match "##\[error\]" # Azure DevOps error markers - } - - foreach ($errorLine in $errorLines) { - # Clean up the line - $cleanLine = $errorLine -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - $cleanLine = $cleanLine -replace "##\[error\]", "" - - # Skip generic "exited with code" errors - we want the actual error - if ($cleanLine -match "exited with code") { - continue - } - - $allResults += [PSCustomObject]@{ - Type = "BuildError" - Source = $task.name - Message = $cleanLine.Trim() - Details = "" - } - } - } - catch { - Write-Warning "Failed to fetch log for task $($task.name): $_" - } - } -} - -# --- SECTION 2: Find Test Failures from Jobs --- -if (-not $ErrorsOnly) { - $jobs = $timeline.records | Where-Object { - $_.type -eq "Job" -and - $_.log.url -and - $_.state -eq "completed" -and - $_.result -eq "failed" -and - (-not $JobFilter -or $_.name -like $JobFilter) - } - - foreach ($job in $jobs) { - Write-Host "Analyzing job for test failures: $($job.name)" -ForegroundColor Yellow - - try { - $logContent = Invoke-RestMethod -Uri $job.log.url -Method Get - $lines = $logContent -split "`n" - - # Find test result lines: "Failed [duration]" - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match "^\d{4}-\d{2}-\d{2}.*\s+Failed\s+(\S+)\s+\[([^\]]+)\]") { - $testName = $matches[1] - $duration = $matches[2] - - $errorMessage = "" - $stackTrace = "" - - # Look ahead for error message and stack trace - for ($j = $i + 1; $j -lt $lines.Count; $j++) { - $line = $lines[$j] - $cleanLine = $line -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - - if ($cleanLine -match "^\s*Error Message:") { - for ($k = $j + 1; $k -lt [Math]::Min($j + 10, $lines.Count); $k++) { - $msgLine = $lines[$k] -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - if ($msgLine -match "^\s*Stack Trace:" -or [string]::IsNullOrWhiteSpace($msgLine)) { - break - } - $errorMessage += $msgLine.Trim() + " " - } - } - - if ($cleanLine -match "^\s*Stack Trace:") { - for ($k = $j + 1; $k -lt [Math]::Min($j + 5, $lines.Count); $k++) { - $stLine = $lines[$k] -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - if ($stLine -match "at .+ in .+:line \d+") { - $stackTrace = $stLine.Trim() - break - } - } - break - } - - # Stop if we hit the next test - if ($cleanLine -match "^\s*(Passed|Failed|Skipped)\s+\S+\s+\[") { - break - } - } - - $allResults += [PSCustomObject]@{ - Type = "TestFailure" - Source = $job.name - Message = $testName - Details = if ($errorMessage) { "$errorMessage`n$stackTrace".Trim() } else { $stackTrace } - } - } - } - } - catch { - Write-Warning "Failed to fetch log for job $($job.name): $_" - } - } -} - -# Remove duplicate errors (same message from same source) -$uniqueResults = $allResults | Group-Object -Property Type, Source, Message | ForEach-Object { - $_.Group | Select-Object -First 1 -} - -# Summary -$buildErrors = ($uniqueResults | Where-Object { $_.Type -eq "BuildError" }).Count -$testFailures = ($uniqueResults | Where-Object { $_.Type -eq "TestFailure" }).Count - -Write-Host "`nSummary: $buildErrors build error(s), $testFailures test failure(s)" -ForegroundColor Cyan - -$uniqueResults diff --git a/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 b/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 deleted file mode 100644 index 12fbc89a5541..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 +++ /dev/null @@ -1,104 +0,0 @@ -<# -.SYNOPSIS - Retrieves detailed status information for an Azure DevOps build. - -.DESCRIPTION - Queries the Azure DevOps build timeline API and returns comprehensive - information about the build including all stages, their status, and - any failed or canceled jobs. - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER FailedOnly - If specified, only returns failed or canceled stages and jobs. - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 -FailedOnly - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 -Org "dnceng-public" -Project "public" - -.OUTPUTS - Object with BuildId, Status, Result, Stages, and FailedJobs properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [switch]$FailedOnly -) - -$ErrorActionPreference = "Stop" - -# Get build info -$buildUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}?api-version=7.0" -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/$BuildId/timeline?api-version=7.0" - -try { - $build = Invoke-RestMethod -Uri $buildUrl -Method Get -ContentType "application/json" - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps API: $_" - exit 1 -} - -# Extract stages -$stages = $timeline.records | Where-Object { $_.type -eq "Stage" } | ForEach-Object { - [PSCustomObject]@{ - Name = $_.name - State = $_.state - Result = $_.result - } -} | Sort-Object -Property { $_.State -eq "completed" }, { $_.State -eq "inProgress" } - -# Extract failed/canceled jobs -$failedJobs = $timeline.records | - Where-Object { - ($_.type -eq "Stage" -or $_.type -eq "Job") -and - ($_.result -eq "failed" -or $_.result -eq "canceled") - } | - ForEach-Object { - [PSCustomObject]@{ - Name = $_.name - Type = $_.type - Result = $_.result - } - } | Sort-Object -Property Type, Name - -if ($FailedOnly) { - $failedJobs -} -else { - [PSCustomObject]@{ - BuildId = $BuildId - BuildNumber = $build.buildNumber - Status = $build.status - Result = $build.result - Pipeline = $build.definition.name - StartTime = $build.startTime - FinishTime = $build.finishTime - Stages = $stages - FailedJobs = $failedJobs - Link = "https://dev.azure.com/$Org/$Project/_build/results?buildId=$BuildId" - } -} diff --git a/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 b/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 deleted file mode 100644 index 64a2801aabeb..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 +++ /dev/null @@ -1,247 +0,0 @@ -<# -.SYNOPSIS - Retrieves Helix console logs for failed work items from an Azure DevOps build. - -.DESCRIPTION - Parses Azure DevOps build logs to extract Helix job IDs, then queries the Helix API - to get console logs for failed work items. This is useful for debugging any test - failures that run on Helix infrastructure (device tests, integration tests, etc.). - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER Platform - Optional filter for platform (e.g., 'Windows', 'iOS', 'Android', 'MacCatalyst'). - -.PARAMETER WorkItem - Optional filter for specific work item name (supports wildcards). - -.PARAMETER ShowConsoleLog - If specified, displays the full console log content for each failed work item. - -.PARAMETER TailLines - Number of lines to show from the end of console logs. Default is 100. - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 -Platform Windows -ShowConsoleLog - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 -WorkItem "*Lifecycle*" -ShowConsoleLog -TailLines 200 - -.OUTPUTS - Objects with JobId, WorkItem, Queue, ConsoleUrl, and optionally ConsoleLog properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [string]$Platform, - - [Parameter(Mandatory = $false)] - [string]$WorkItem, - - [Parameter(Mandatory = $false)] - [switch]$ShowConsoleLog, - - [Parameter(Mandatory = $false)] - [int]$TailLines = 100 -) - -$ErrorActionPreference = "Stop" - -# Get build timeline -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}/timeline?api-version=7.0" - -try { - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps timeline API: $_" - exit 1 -} - -# Build platform filter pattern -$platformPattern = if ($Platform) { - switch ($Platform.ToLower()) { - "windows" { "*Windows*" } - "ios" { "*iOS*" } - "android" { "*Android*" } - "maccatalyst" { "*MacCatalyst*|*Catalyst*" } - default { "*$Platform*" } - } -} else { - "*" -} - -# Find Helix-related tasks with logs (looking for DeviceTests tasks that submit to Helix) -$helixTasks = $timeline.records | Where-Object { - $_.name -like "*DeviceTests*" -and - $_.log.url -and - ($_.result -eq "failed" -or $_.result -eq "succeeded") -and - (-not $Platform -or $_.name -like $platformPattern) -} - -if (-not $helixTasks) { - Write-Host "No Helix-related tasks found in build $BuildId" -ForegroundColor Yellow - exit 0 -} - -$allHelixJobs = @{} -$allResults = @() - -foreach ($task in $helixTasks) { - Write-Host "Scanning task: $($task.name) [$($task.result)]" -ForegroundColor $(if ($task.result -eq "failed") { "Red" } else { "Gray" }) - - try { - $logContent = Invoke-RestMethod -Uri $task.log.url -Method Get - - # Extract Helix job IDs from log (pattern: "jobs/{guid}/workitems") - $jobMatches = [regex]::Matches($logContent, "jobs/([a-f0-9-]{36})/workitems") - - foreach ($match in $jobMatches) { - $jobId = $match.Groups[1].Value - if (-not $allHelixJobs.ContainsKey($jobId)) { - $allHelixJobs[$jobId] = @{ - Task = $task.name - Result = $task.result - } - } - } - } - catch { - Write-Warning "Failed to fetch log for task $($task.name): $_" - } -} - -if ($allHelixJobs.Count -eq 0) { - Write-Host "No Helix job IDs found in build logs" -ForegroundColor Yellow - exit 0 -} - -Write-Host "`nFound $($allHelixJobs.Count) Helix job(s)" -ForegroundColor Cyan - -# Query each Helix job for work items -foreach ($jobId in $allHelixJobs.Keys) { - $jobInfo = $allHelixJobs[$jobId] - Write-Host "`n--- Helix Job: $jobId ---" -ForegroundColor Yellow - Write-Host "From task: $($jobInfo.Task)" -ForegroundColor Gray - - try { - # Get job details for queue info - $jobDetailsUrl = "https://helix.dot.net/api/jobs/${jobId}/details?api-version=2019-06-17" - $jobDetails = Invoke-RestMethod -Uri $jobDetailsUrl -Method Get - $queue = $jobDetails.QueueId - - Write-Host "Queue: $queue" -ForegroundColor Gray - - # Get work items - $workItemsUrl = "https://helix.dot.net/api/jobs/${jobId}/workitems?api-version=2019-06-17" - $workItems = Invoke-RestMethod -Uri $workItemsUrl -Method Get - - foreach ($wi in $workItems) { - # Skip the controller work item - if ($wi.Name -eq "HelixController Work Queueing") { - continue - } - - # Apply work item filter if specified - if ($WorkItem -and $wi.Name -notlike $WorkItem) { - continue - } - - $consoleUrl = "https://helix.dot.net/api/2019-06-17/jobs/${jobId}/workitems/$($wi.Name)/console" - - # Determine if this is a failure - # Check: 1) ExitCode non-zero, 2) Parent task failed - $isFailed = $false - if ($wi.ExitCode -and $wi.ExitCode -ne 0) { - $isFailed = $true - } - elseif ($jobInfo.Result -eq "failed") { - $isFailed = $true - } - - # For work items from failed tasks, always try to get console log to check for errors - $shouldFetchLog = $ShowConsoleLog -and ($isFailed -or $jobInfo.Result -eq "failed") - - $consoleLogContent = $null - if ($shouldFetchLog) { - Write-Host "`nFetching console log for: $($wi.Name)" -ForegroundColor Cyan - - try { - $consoleLog = Invoke-RestMethod -Uri $consoleUrl -Method Get - - # Check if the log indicates a failure (even if ExitCode wasn't set) - if ($consoleLog -match "exited with (code )?1\]|ERROR:|FAILED|exception|crash") { - $isFailed = $true - } - - # Get tail lines - $lines = $consoleLog -split "`n" - if ($TailLines -gt 0 -and $lines.Count -gt $TailLines) { - $consoleLogContent = ($lines | Select-Object -Last $TailLines) -join "`n" - Write-Host "... (showing last $TailLines of $($lines.Count) lines)" -ForegroundColor Gray - } - else { - $consoleLogContent = $consoleLog - } - - Write-Host $consoleLogContent - } - catch { - Write-Warning "Failed to fetch console log: $_" - } - } - - $result = [PSCustomObject]@{ - JobId = $jobId - WorkItem = $wi.Name - State = $wi.State - ExitCode = $wi.ExitCode - Queue = $queue - IsFailed = $isFailed - ConsoleUrl = $consoleUrl - ConsoleLog = $consoleLogContent - } - - $allResults += $result - - # Print summary line - $statusColor = if ($isFailed) { "Red" } else { "Green" } - $statusSymbol = if ($isFailed) { "X" } else { "√" } - Write-Host " [$statusSymbol] $($wi.Name) (Exit: $($wi.ExitCode))" -ForegroundColor $statusColor - } - } - catch { - Write-Warning "Failed to query Helix job $jobId`: $_" - } -} - -# Summary -$failedCount = ($allResults | Where-Object { $_.IsFailed }).Count -$totalCount = $allResults.Count - -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "Summary: $failedCount failed / $totalCount total work items" -ForegroundColor $(if ($failedCount -gt 0) { "Red" } else { "Green" }) -Write-Host "========================================" -ForegroundColor Cyan - -# Output results -$allResults diff --git a/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 b/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 deleted file mode 100644 index 8df3966a3577..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 +++ /dev/null @@ -1,65 +0,0 @@ -<# -.SYNOPSIS - Retrieves Azure DevOps build IDs associated with a GitHub PR. - -.DESCRIPTION - Queries GitHub PR checks and extracts the Azure DevOps build IDs, - pipeline names, states, and links for each unique build. - -.PARAMETER PrNumber - The GitHub Pull Request number. - -.PARAMETER Repo - The GitHub repository in 'owner/repo' format. Defaults to 'dotnet/maui'. - -.EXAMPLE - ./Get-PrBuildIds.ps1 -PrNumber 33251 - -.EXAMPLE - ./Get-PrBuildIds.ps1 -PrNumber 33251 -Repo "dotnet/maui" - -.OUTPUTS - Array of objects with Pipeline, BuildId, State, and Link properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [int]$PrNumber, - - [Parameter(Mandatory = $false)] - [string]$Repo = "dotnet/maui" -) - -$ErrorActionPreference = "Stop" - -# Validate prerequisites -if (-not (Get-Command "gh" -ErrorAction SilentlyContinue)) { - Write-Error "GitHub CLI (gh) is not installed. Install from https://cli.github.com/" - exit 1 -} - -# Get PR checks from GitHub -$checksJson = gh pr checks $PrNumber --repo $Repo --json name,link,state 2>&1 - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to get PR checks: $checksJson" - exit 1 -} - -$checks = $checksJson | ConvertFrom-Json - -# Filter to Azure DevOps checks and extract build IDs -$builds = $checks | Where-Object { $_.link -match "dev\.azure\.com" } | ForEach-Object { - $buildId = if ($_.link -match "buildId=(\d+)") { $matches[1] } else { $null } - $pipeline = ($_.name -split " ")[0] - - [PSCustomObject]@{ - Pipeline = $pipeline - BuildId = $buildId - State = $_.state - Link = $_.link - } -} | Sort-Object -Property Pipeline, BuildId -Unique - -$builds diff --git a/.github/skills/pr-finalize/SKILL.md b/.github/skills/pr-finalize/SKILL.md index c1c8af7144de..d2efd2d25ebf 100644 --- a/.github/skills/pr-finalize/SKILL.md +++ b/.github/skills/pr-finalize/SKILL.md @@ -1,6 +1,6 @@ --- name: pr-finalize -description: Finalizes any PR for merge by verifying title/description match implementation AND performing code review for best practices. Use when asked to "finalize PR", "check PR description", "review commit message", before merging any PR, or when PR implementation changed during review. Do NOT use for extracting lessons (use learn-from-pr), writing tests (use write-tests-agent), or investigating build failures (use pr-build-status). +description: Finalizes any PR for merge by verifying title/description match implementation AND performing code review for best practices. Use when asked to "finalize PR", "check PR description", "review commit message", before merging any PR, or when PR implementation changed during review. Do NOT use for extracting lessons (use learn-from-pr), writing tests (use write-tests-agent), or investigating build failures (use azdo-build-investigator and ci-analysis). --- # PR Finalize