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; }