Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/agents/pr.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
82 changes: 26 additions & 56 deletions .github/scripts/BuildAndRunHostApp.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -238,41 +240,22 @@ 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"
if (Test-Path $executablePath) {
& 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`""
Expand Down Expand Up @@ -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"
Expand Down
100 changes: 100 additions & 0 deletions src/Controls/tests/TestCases.HostApp/FileLoggingProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using Microsoft.Extensions.Logging;

namespace Maui.Controls.Sample;

/// <summary>
/// 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.
/// </summary>
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>(TState state) where TState : notnull => NullScope.Instance;

public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> 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() { }
}
}
22 changes: 20 additions & 2 deletions src/Controls/tests/TestCases.HostApp/MauiProgram.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
using System.Diagnostics;
using Maui.Controls.Sample.Issues;
using Microsoft.Extensions.Logging;

[assembly: XamlCompilation(XamlCompilationOptions.Compile)]

namespace Maui.Controls.Sample
{
public static partial class MauiProgram
{
/// <summary>
/// Gets the file logging path from MAUI_LOG_FILE environment variable.
/// Returns null if not set (file logging disabled).
/// </summary>
static string GetFileLogPath()
{
return Environment.GetEnvironmentVariable("MAUI_LOG_FILE");
}

public static MauiApp CreateMauiApp()
{
var appBuilder = MauiApp.CreateBuilder();

#if IOS || ANDROID || MACCATALYST
appBuilder.UseMauiMaps();
#endif

appBuilder.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
Expand All @@ -33,10 +44,9 @@ public static MauiApp CreateMauiApp()
.Issue25436RegisterNavigationService();

#if IOS || MACCATALYST

appBuilder.ConfigureCollectionViewHandlers();

#endif

// Register the custom handler
appBuilder.ConfigureMauiHandlers(handlers =>
{
Expand All @@ -59,6 +69,14 @@ public static MauiApp CreateMauiApp()

appBuilder.Services.AddTransient<TransientPage>();
appBuilder.Services.AddScoped<ScopedPage>();

// 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();
}

Expand Down
7 changes: 7 additions & 0 deletions src/Controls/tests/TestCases.Shared.Tests/UITest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading