diff --git a/.github/agents/pr.md b/.github/agents/pr.md
index 6f0cec11f12a..b2c6fbe67805 100644
--- a/.github/agents/pr.md
+++ b/.github/agents/pr.md
@@ -1,6 +1,6 @@
---
name: pr
-description: "Sequential 5-phase workflow for GitHub issues: Pre-Flight, Tests, Gate, Fix, Report. Phases MUST complete in order. State tracked in .github/agent-pr-session/."
+description: Sequential 5-phase workflow for GitHub issues - Pre-Flight, Tests, Gate, Fix, Report. Phases MUST complete in order. State tracked in .github/agent-pr-session/
---
# .NET MAUI Pull Request Agent
diff --git a/.github/scripts/BuildAndRunHostApp.ps1 b/.github/scripts/BuildAndRunHostApp.ps1
index 00f38d221c10..8d1e8250260e 100644
--- a/.github/scripts/BuildAndRunHostApp.ps1
+++ b/.github/scripts/BuildAndRunHostApp.ps1
@@ -47,7 +47,7 @@
[CmdletBinding(DefaultParameterSetName = "TestFilter")]
param(
[Parameter(Mandatory = $true)]
- [ValidateSet("android", "ios", "catalyst")]
+ [ValidateSet("android", "ios", "catalyst", "maccatalyst")]
[string]$Platform,
[Parameter(Mandatory = $true, ParameterSetName = "TestFilter")]
@@ -70,6 +70,11 @@ $RepoRoot = Resolve-Path "$PSScriptRoot/../.."
$HostAppProject = Join-Path $RepoRoot "src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj"
$HostAppLogsDir = Join-Path $RepoRoot "CustomAgentLogsTmp/UITests"
+# Normalize platform name (accept both "catalyst" and "maccatalyst")
+if ($Platform -eq "maccatalyst") {
+ $Platform = "catalyst"
+}
+
# Import shared utilities
. "$PSScriptRoot/shared/shared-utils.ps1"
@@ -93,18 +98,15 @@ if (-not (Test-Path $HostAppLogsDir)) {
Write-Info "Created CustomAgentLogsTmp/UITests directory"
}
-# Clean up old log files from previous runs
+# Clean up ALL old log files from previous runs to avoid confusion
$deviceLogFile = Join-Path $HostAppLogsDir "$Platform-device.log"
$testOutputFile = Join-Path $HostAppLogsDir "test-output.log"
-if (Test-Path $deviceLogFile) {
- Remove-Item $deviceLogFile -Force
- Write-Info "Cleaned up old $Platform-device.log"
-}
-
-if (Test-Path $testOutputFile) {
- Remove-Item $testOutputFile -Force
- Write-Info "Cleaned up old test-output.log"
+# Remove all files in the logs directory
+$existingFiles = Get-ChildItem -Path $HostAppLogsDir -File -ErrorAction SilentlyContinue
+if ($existingFiles) {
+ $existingFiles | Remove-Item -Force
+ Write-Info "Cleaned up $($existingFiles.Count) old log file(s) from previous runs"
}
# Check if dotnet is available
@@ -225,8 +227,8 @@ $testStartTime = Get-Date
# For MacCatalyst, launch the app BEFORE running tests so Appium finds the correct bundle
# This is critical because both maui and maui2 repos may share the same bundle ID
-# We use dotnet run with StandardOutputPath/StandardErrorPath to capture Console.WriteLine
-# See: https://github.com/dotnet/macios/blob/main/docs/building-apps/build-properties.md#runwithopen
+# MacCatalyst: Just ensure the app is ready - Appium will launch it with the test name
+# The app has built-in file logging that writes directly to MAUI_LOG_FILE path
$catalystAppProcess = $null
if ($Platform -eq "catalyst") {
# Determine runtime identifier
@@ -238,8 +240,7 @@ if ($Platform -eq "catalyst") {
$appPath = [System.IO.Path]::GetFullPath($appPath)
if (Test-Path $appPath) {
- Write-Info "Launching MacCatalyst app with dotnet run..."
- Write-Info "App path: $appPath"
+ Write-Info "MacCatalyst app ready at: $appPath"
# Make executable (like CI does)
$executablePath = Join-Path $appPath "Contents/MacOS/Controls.TestCases.HostApp"
@@ -247,32 +248,14 @@ if ($Platform -eq "catalyst") {
& chmod +x $executablePath
}
- # Use dotnet run with StandardOutputPath/StandardErrorPath
- # This launches the app via 'open' but captures stdout/stderr to files
- # Console.WriteLine on MacCatalyst goes to stderr
- $stderrFile = "$deviceLogFile.stderr"
- $hostAppProject = Join-Path $PSScriptRoot "../../src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj"
- $hostAppProject = [System.IO.Path]::GetFullPath($hostAppProject)
-
- Write-Info "Starting app with dotnet run (logs to $stderrFile)..."
- & dotnet run --project $hostAppProject -f $TargetFramework --no-build `
- -p:StandardOutputPath=$deviceLogFile `
- -p:StandardErrorPath=$stderrFile 2>&1 | Out-Null
-
- # dotnet run exits immediately when using 'open', give app time to launch
- Start-Sleep -Seconds 3
-
- # Get app process ID for later cleanup
- $catalystAppProcess = Get-Process -Name "Controls.TestCases.HostApp" -ErrorAction SilentlyContinue | Select-Object -First 1
- if ($catalystAppProcess) {
- Write-Success "MacCatalyst app launched (PID: $($catalystAppProcess.Id))"
- } else {
- Write-Success "MacCatalyst app launched with log capture"
- }
+ Write-Success "MacCatalyst app prepared (Appium will launch with test name)"
} else {
Write-Warning "MacCatalyst app not found at: $appPath"
Write-Warning "Test may use wrong app bundle if another version is registered"
}
+
+ # Set log file path directly - app will write ILogger output here
+ $env:MAUI_LOG_FILE = $deviceLogFile
}
Write-Info "Executing: dotnet test --filter `"$effectiveFilter`""
@@ -342,26 +325,13 @@ if ($Platform -eq "android") {
Write-Info "iOS logs saved to: $deviceLogFile"
} elseif ($Platform -eq "catalyst") {
- # On macOS, Console.WriteLine goes to stderr, not stdout
- # Stderr was captured to $deviceLogFile.stderr, stdout to $deviceLogFile
- Write-Info "MacCatalyst logs captured via stdout/stderr redirect during test execution"
-
- # Check stderr first - this is where Console.WriteLine output goes on macOS
- $stderrFile = "$deviceLogFile.stderr"
- if ((Test-Path $stderrFile) -and ((Get-Item $stderrFile).Length -gt 0)) {
- Write-Info "Console.WriteLine output found in stderr..."
- # Copy stderr content to main log file (overwrite since stderr is the useful one)
- Get-Content $stderrFile | Set-Content -Path $deviceLogFile -Encoding UTF8
- }
-
- # If log file is still empty or small, try log show as fallback (for Debug.WriteLine via os_log)
- $logFileSize = 0
- if (Test-Path $deviceLogFile) {
- $logFileSize = (Get-Item $deviceLogFile).Length
- }
-
- if ($logFileSize -lt 100) {
- Write-Info "Console output was minimal, using os_log fallback (captures Debug.WriteLine)..."
+ # App writes directly to $deviceLogFile via MAUI_LOG_FILE env var
+ # Just verify the file exists and has content
+ if ((Test-Path $deviceLogFile) -and ((Get-Item $deviceLogFile).Length -gt 0)) {
+ Write-Success "MacCatalyst logs written directly to: $deviceLogFile"
+ } else {
+ # Fall back to os_log if file logging didn't work
+ Write-Info "File logging output was minimal, using os_log fallback..."
$logStartTimeStr = $testStartTime.AddMinutes(-1).ToString("yyyy-MM-dd HH:mm:ss")
$catalystLogCommand = "log show --level debug --predicate 'process contains `"Controls.TestCases.HostApp`" OR processImagePath contains `"Controls.TestCases.HostApp`"' --start `"$logStartTimeStr`" --style compact"
Invoke-Expression "$catalystLogCommand > `"$deviceLogFile`" 2>&1"
diff --git a/src/Controls/tests/TestCases.HostApp/FileLoggingProvider.cs b/src/Controls/tests/TestCases.HostApp/FileLoggingProvider.cs
new file mode 100644
index 000000000000..0493ca088313
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/FileLoggingProvider.cs
@@ -0,0 +1,100 @@
+using Microsoft.Extensions.Logging;
+
+namespace Maui.Controls.Sample;
+
+///
+/// A simple file-based logging provider for capturing ILogger output during UI tests.
+/// Enabled when the MAUI_LOG_FILE environment variable is set to the desired log file path.
+///
+internal class FileLoggingProvider : ILoggerProvider
+{
+ private readonly StreamWriter _writer;
+ private readonly LogLevel _minLevel;
+
+ public FileLoggingProvider(string filePath, LogLevel minLevel = LogLevel.Debug)
+ {
+ _minLevel = minLevel;
+ var directory = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+ _writer = new StreamWriter(filePath, append: false) { AutoFlush = true };
+ _writer.WriteLine($"=== MAUI HostApp File Logger Started at {DateTime.Now} ===");
+ _writer.WriteLine($"Log file: {filePath}");
+ _writer.WriteLine($"Minimum log level: {minLevel}");
+ _writer.WriteLine();
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new FileLogger(categoryName, _writer, _minLevel);
+ }
+
+ public void Dispose()
+ {
+ _writer?.Dispose();
+ }
+}
+
+internal class FileLogger : ILogger
+{
+ private readonly string _categoryName;
+ private readonly StreamWriter _writer;
+ private readonly LogLevel _minLevel;
+ private static readonly object _lock = new object();
+
+ public FileLogger(string categoryName, StreamWriter writer, LogLevel minLevel)
+ {
+ _categoryName = categoryName;
+ _writer = writer;
+ _minLevel = minLevel;
+ }
+
+ public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
+
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ return;
+
+ var message = formatter(state, exception);
+ var levelString = logLevel switch
+ {
+ LogLevel.Trace => "TRACE",
+ LogLevel.Debug => "DEBUG",
+ LogLevel.Information => "INFO",
+ LogLevel.Warning => "WARN",
+ LogLevel.Error => "ERROR",
+ LogLevel.Critical => "CRIT",
+ _ => "NONE"
+ };
+
+ var timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
+ var logLine = $"[{timestamp}] [{levelString}] {_categoryName}: {message}";
+
+ lock (_lock)
+ {
+ _writer.WriteLine(logLine);
+ if (exception != null)
+ {
+ _writer.WriteLine($" Exception: {exception}");
+ }
+ }
+
+ // Also write to console for local debugging visibility
+ Console.WriteLine(logLine);
+ if (exception != null)
+ {
+ Console.WriteLine($" Exception: {exception}");
+ }
+ }
+
+ private class NullScope : IDisposable
+ {
+ public static NullScope Instance { get; } = new NullScope();
+ public void Dispose() { }
+ }
+}
diff --git a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs
index 04249485b47d..b4583ac95321 100644
--- a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs
+++ b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using Maui.Controls.Sample.Issues;
+using Microsoft.Extensions.Logging;
[assembly: XamlCompilation(XamlCompilationOptions.Compile)]
@@ -7,6 +8,15 @@ namespace Maui.Controls.Sample
{
public static partial class MauiProgram
{
+ ///
+ /// Gets the file logging path from MAUI_LOG_FILE environment variable.
+ /// Returns null if not set (file logging disabled).
+ ///
+ static string GetFileLogPath()
+ {
+ return Environment.GetEnvironmentVariable("MAUI_LOG_FILE");
+ }
+
public static MauiApp CreateMauiApp()
{
var appBuilder = MauiApp.CreateBuilder();
@@ -14,6 +24,7 @@ public static MauiApp CreateMauiApp()
#if IOS || ANDROID || MACCATALYST
appBuilder.UseMauiMaps();
#endif
+
appBuilder.UseMauiApp()
.ConfigureFonts(fonts =>
{
@@ -33,10 +44,9 @@ public static MauiApp CreateMauiApp()
.Issue25436RegisterNavigationService();
#if IOS || MACCATALYST
-
appBuilder.ConfigureCollectionViewHandlers();
-
#endif
+
// Register the custom handler
appBuilder.ConfigureMauiHandlers(handlers =>
{
@@ -59,6 +69,14 @@ public static MauiApp CreateMauiApp()
appBuilder.Services.AddTransient();
appBuilder.Services.AddScoped();
+
+ // Add file logging if MAUI_LOG_FILE environment variable is set
+ var logFilePath = GetFileLogPath();
+ if (!string.IsNullOrEmpty(logFilePath))
+ {
+ appBuilder.Logging.AddProvider(new FileLoggingProvider(logFilePath, LogLevel.Debug));
+ }
+
return appBuilder.Build();
}
diff --git a/src/Controls/tests/TestCases.Shared.Tests/UITest.cs b/src/Controls/tests/TestCases.Shared.Tests/UITest.cs
index b2821aac5d90..9d28a841ac33 100644
--- a/src/Controls/tests/TestCases.Shared.Tests/UITest.cs
+++ b/src/Controls/tests/TestCases.Shared.Tests/UITest.cs
@@ -102,6 +102,13 @@ public override IConfig GetTestConfig()
config.SetTestConfigurationArg("TEST_CONFIGURATION_ARGS", commandLineArgs);
}
+ // Pass log file path to app if set - app will write ILogger output to this file
+ var logFilePath = Environment.GetEnvironmentVariable("MAUI_LOG_FILE") ?? "";
+ if (!String.IsNullOrEmpty(logFilePath))
+ {
+ config.SetTestConfigurationArg("MAUI_LOG_FILE", logFilePath);
+ }
+
return config;
}