Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Jun 19, 2025

This PR implements a complete visual test runner for WASM environments using Blazor as the host framework, extending DeviceRunners' cross-platform testing capabilities to web browsers.

What's Added

Core Blazor Visual Runner Library

  • src/DeviceRunners.VisualRunners.Blazor/ - New Razor class library providing WASM-compatible visual test runner components
  • Blazor components that mirror the existing MAUI pages:
    • HomePage.razor - Test assembly overview and execution
    • TestAssembly.razor - Individual test case management
    • TestResult.razor - Detailed test result display
    • Diagnostics.razor - Runtime diagnostics information
    • Credits.razor - Application information
  • MainLayout.razor and NavMenu.razor for consistent navigation
  • Responsive CSS styling optimized for web browsers

WebAssembly Configuration

  • WebAssemblyAppBuilderExtensions.cs - Configuration extensions for WASM host builder
  • WebAssemblyVisualTestRunnerConfigurationBuilder.cs - WASM-specific configuration builder
  • Full integration with existing DeviceRunners.VisualRunners ViewModels and services

Sample Projects

  • sample/test/DeviceTestingKitApp.WasmTests/ - Complete WASM test runner application
    • Demonstrates running Xunit tests in browser environment
    • Includes service worker for PWA capabilities
    • Responsive web manifest configuration
  • sample/test/DeviceTestingKitApp.BlazorLibrary.XunitTests/ - Sample test library
    • Example Xunit tests for WASM environment
    • Demonstrates passing, failing, and skipped test scenarios

Key Features

  • Framework Consistency: Reuses all existing ViewModels from DeviceRunners.VisualRunners, ensuring consistent behavior across platforms
  • Cross-Platform UI: Same visual test runner experience now available on Android, iOS, macOS, Windows, and WASM
  • Web-Optimized: Responsive design that works well in desktop and mobile browsers
  • Progressive Web App: Service worker and manifest support for installable web app experience
  • Test Framework Support: Full Xunit integration with extensibility for other frameworks

Architecture

The implementation follows the established DeviceRunners pattern:

  • Core testing logic remains in DeviceRunners.VisualRunners
  • Platform-specific UI implementation in DeviceRunners.VisualRunners.Blazor
  • ViewModels are shared across MAUI and Blazor implementations
  • Configuration builders provide platform-appropriate service registration

Example Usage

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<DeviceRunners.VisualRunners.Blazor.Components.App.App>("#app");

// Configure the test runner
builder.UseVisualTestRunner(conf => conf
    .AddConsoleResultChannel()
    .AddTestAssembly(typeof(Program).Assembly)
    .AddTestAssemblies(typeof(MyTests).Assembly)
    .AddXunit());

await builder.Build().RunAsync();

This enables the same comprehensive device testing capabilities that were previously limited to native platforms to now run in any modern web browser, expanding the reach of .NET testing to cloud environments, CI/CD pipelines, and developer workflows that benefit from web-based test execution.

Fixes #82.


💬 Share your feedback on Copilot coding agent for the chance to win a $200 gift card! Click here to start the survey.

Copilot AI changed the title [WIP] Create the visual runner for WASM Create visual runner for WASM using Blazor Jun 19, 2025
Copilot AI requested a review from mattleibow June 19, 2025 01:19
@mattleibow
Copy link
Owner

/rebase

Copy link
Owner

@mattleibow mattleibow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is amazing, unfortunately WASM does not have threads, so we need to avoid using the defaults.

I have a bit of code that I use to discover tests on WASM in another project. This uses a threadless model and allows everything to run synchronously. Can you look at this and use it to make the a new test component.

Maybe this could be a .AddThreadlessXunit() and then it could add a new xunit test discoverer or xunit test runner.

Here is the code:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices.JavaScript;
using System.Text;
using System.Xml.Linq;
using Xunit;
using Xunit.Abstractions;

namespace Xunit.Sdk;

internal partial class ThreadlessXunitTestRunner
{
	[JSImport("xunit.sdk.runner.clearLog", "main.js")]
	internal static partial void ClearLog();

	[JSImport("xunit.sdk.runner.log", "main.js")]
	internal static partial void Log(string? message, string? type = null, string? id = null);

	[JSImport("xunit.sdk.runner.logResults", "main.js")]
	internal static partial void LogResults(string results);

	public bool Run(string assemblyFileName, IEnumerable<string> excludedTraits)
	{
		ClearLog();

		Log("Starting tests...");

		var filters = new XunitFilters();
		foreach (var trait in excludedTraits ?? Array.Empty<string>())
		{
			ParseEqualSeparatedArgument(filters.ExcludedTraits, trait);
		}

		var configuration = new TestAssemblyConfiguration
		{
			ShadowCopy = false,
			ParallelizeAssembly = false,
			ParallelizeTestCollections = false,
			MaxParallelThreads = 1,
			PreEnumerateTheories = false
		};
		var discoveryOptions = TestFrameworkOptions.ForDiscovery(configuration);
		var discoverySink = new TestDiscoverySink();
		var diagnosticSink = new ConsoleDiagnosticMessageSink();
		var testOptions = TestFrameworkOptions.ForExecution(configuration);
		var testSink = new TestMessageSink();
		var controller = new Xunit2(
			AppDomainSupport.Denied,
			new NullSourceInformationProvider(),
			assemblyFileName,
			configFileName: null,
			shadowCopy: false,
			shadowCopyFolder: null,
			diagnosticMessageSink: diagnosticSink,
			verifyTestAssemblyExists: false);

		discoveryOptions.SetSynchronousMessageReporting(true);
		testOptions.SetSynchronousMessageReporting(true);

		Log($"Discovering tests for {assemblyFileName}...");
		var assembly = Assembly.LoadFrom(assemblyFileName);
		var assemblyInfo = new Xunit.Sdk.ReflectionAssemblyInfo(assembly);
		var discoverer = new ThreadlessXunitDiscoverer(assemblyInfo, new NullSourceInformationProvider(), discoverySink);
		discoverer.FindWithoutThreads(includeSourceInformation: false, discoverySink, discoveryOptions);
		discoverySink.Finished.WaitOne();
		var testCasesToRun = discoverySink.TestCases.Where(filters.Filter).ToList();
		Log($"Discovered tests for {assemblyFileName}: Total: {discoverySink.TestCases.Count}, Skip: {discoverySink.TestCases.Count - testCasesToRun.Count}, Run: {testCasesToRun.Count}.");
		Log($"Test discovery finished.");
		Log("");

		var summarySink = new DelegatingExecutionSummarySink(
			testSink,
			() => false,
			(completed, summary) =>
			{
				Log($"""
					Tests run: {summary.Total}, Errors: 0, Failures: {summary.Failed}, Skipped: {summary.Skipped}.
					Total duration: {TimeSpan.FromSeconds((double)summary.Time).TotalSeconds}s
					""");
			});

		var resultsXmlAssembly = new XElement("assembly");
		var resultsSink = new DelegatingXmlCreationSink(summarySink, resultsXmlAssembly);

		testSink.Execution.TestPassedEvent += args => { Log($"[PASS] {args.Message.Test.DisplayName}", type: "pass"); };
		testSink.Execution.TestSkippedEvent += args => { Log($"[SKIP] {args.Message.Test.DisplayName}", type: "skip"); };
		testSink.Execution.TestFailedEvent += args =>
		{
			Log($"""
				[FAIL] {args.Message.Test.DisplayName}
				{ExceptionUtility.CombineMessages(args.Message)}
				{ExceptionUtility.CombineStackTraces(args.Message)}
				""", type: "fail");
		};

		testSink.Execution.TestAssemblyStartingEvent += args => { Log($"Running tests for {args.Message.TestAssembly.Assembly}..."); };
		testSink.Execution.TestAssemblyFinishedEvent += args => { Log($"Finished running tests for {args.Message.TestAssembly.Assembly}."); };

		controller.RunTests(testCasesToRun, resultsSink, testOptions);
		resultsSink.Finished.WaitOne();

		var resultsXml = new XElement("assemblies");
		resultsXml.Add(resultsXmlAssembly);

		Console.WriteLine(resultsXml.ToString());

		Log("");
		Log("Test results (Base64 encoded):");
		var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(resultsXml.ToString()));
		Console.WriteLine(base64);
		LogResults(base64);

		return resultsSink.ExecutionSummary.Failed > 0 || resultsSink.ExecutionSummary.Errors > 0;
	}

	private void ParseEqualSeparatedArgument(Dictionary<string, List<string>> targetDictionary, string argument)
	{
		var parts = argument.Split('=');
		if (parts.Length != 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
			throw new ArgumentException("Invalid argument value '{argument}'.", nameof(argument));

		var name = parts[0];
		var value = parts[1];

		List<string> excludedTraits;
		if (targetDictionary.TryGetValue(name, out excludedTraits!))
			excludedTraits.Add(value);
		else
			targetDictionary[name] = new List<string> { value };
	}
}

internal class ThreadlessXunitDiscoverer : Xunit.Sdk.XunitTestFrameworkDiscoverer
{
	public ThreadlessXunitDiscoverer(IAssemblyInfo assemblyInfo, ISourceInformationProvider sourceProvider, IMessageSink diagnosticMessageSink)
		: base(assemblyInfo, sourceProvider, diagnosticMessageSink)
	{
	}

	public void FindWithoutThreads(bool includeSourceInformation, IMessageSink discoveryMessageSink, ITestFrameworkDiscoveryOptions discoveryOptions)
	{
		using var messageBus = new Xunit.Sdk.SynchronousMessageBus(discoveryMessageSink);

		foreach (var type in AssemblyInfo.GetTypes(includePrivateTypes: false).Where(IsValidTestClass))
		{
			var testClass = CreateTestClass(type);
			if (!FindTestsForType(testClass, includeSourceInformation, messageBus, discoveryOptions))
				break;
		}

		messageBus.QueueMessage(new Xunit.Sdk.DiscoveryCompleteMessage());
	}
}

internal class ConsoleDiagnosticMessageSink : Xunit.Sdk.LongLivedMarshalByRefObject, IMessageSink
{
	public bool OnMessage(IMessageSinkMessage message)
	{
		if (message is IDiagnosticMessage diagnosticMessage)
			Console.WriteLine(diagnosticMessage.Message);

		return true;
	}
}

Copy link
Contributor Author

Copilot AI commented Jun 20, 2025

This is amazing, unfortunately WASM does not have threads, so we need to avoid using the defaults.

I have a bit of code that I use to discover tests on WASM in another project. This uses a threadle...

Implemented threadless Xunit support for WASM compatibility. Added ThreadlessXunitTestDiscoverer and ThreadlessXunitTestRunner classes that avoid threading issues in WebAssembly environments. The new .AddThreadlessXunit() extension method is now available and has been integrated into the WASM sample project. The implementation follows the patterns you provided and ensures synchronous test execution suitable for WASM's single-threaded model.

Copilot AI requested a review from mattleibow June 20, 2025 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create the visual runner for WASM

2 participants