Skip to content

Add automated external link validation test for WinUI Gallery with dynamic discovery #1986

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
272 changes: 272 additions & 0 deletions tests/WinUIGallery.UnitTests/UnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Windows.Foundation;
Expand Down Expand Up @@ -184,6 +188,274 @@ private void ExecuteOnUIThread(Action action)
}
}

[TestMethod]
public async Task TestExternalLinksValidity()
{
// Dynamically discover all external links from the project
var externalLinks = await GetAllExternalLinksAsync();

Assert.IsTrue(externalLinks.Count > 0, "No external links found in project");

using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(30); // Set reasonable timeout

// Add user agent to avoid some servers blocking the request
httpClient.DefaultRequestHeaders.Add("User-Agent", "WinUI-Gallery-LinkValidator/1.0");

var failedLinks = new List<string>();
var checkedCount = 0;

foreach (var link in externalLinks)
{
try
{
using var response = await httpClient.GetAsync(link);
checkedCount++;

// Consider 2xx and 3xx status codes as success
// Also accept some 4xx codes that might be normal for redirect services
if (!response.IsSuccessStatusCode &&
(int)response.StatusCode >= 400 &&
response.StatusCode != System.Net.HttpStatusCode.TooManyRequests)
{
failedLinks.Add($"{link} - Status: {response.StatusCode}");
}
}
catch (HttpRequestException ex) when (ex.Message.Contains("Name or service not known") ||
ex.Message.Contains("temporarily unavailable"))
{
// Skip DNS resolution failures in test environments
checkedCount++;
continue;
}
catch (HttpRequestException ex)
{
failedLinks.Add($"{link} - HttpRequestException: {ex.Message}");
checkedCount++;
}
catch (TaskCanceledException ex)
{
failedLinks.Add($"{link} - Timeout: {ex.Message}");
checkedCount++;
}
catch (Exception ex)
{
failedLinks.Add($"{link} - Exception: {ex.Message}");
checkedCount++;
}

// Add small delay to be respectful to servers
await Task.Delay(100);
}

// Assert that we checked a reasonable number of links and no critical failures occurred
Assert.IsTrue(checkedCount > 0, "No links were checked");

// If more than 50% of links fail, it's likely a test environment issue
if (failedLinks.Count > externalLinks.Count / 2)
{
Assert.Inconclusive($"More than 50% of links failed ({failedLinks.Count}/{externalLinks.Count}), " +
"which may indicate test environment network restrictions. " +
$"Checked {checkedCount} links. Found {externalLinks.Count} total external links.");
}

// Assert that no links failed with actual HTTP errors (not network issues)
var httpErrorFailures = failedLinks.Where(f => f.Contains("Status:") &&
!f.Contains("TooManyRequests")).ToList();

if (httpErrorFailures.Any())
{
var failureMessage = $"The following external links returned HTTP errors:\n{string.Join("\n", httpErrorFailures)}";
Assert.Fail(failureMessage);
}
}

/// <summary>
/// Dynamically discovers all external links from XAML files and ControlInfoData.json
/// </summary>
private async Task<List<string>> GetAllExternalLinksAsync()
{
var allLinks = new HashSet<string>();

// Get the project root directory (assuming test is running in bin/Debug/net8.0-windows10.0.19041.0 or similar)
var testDirectory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
var projectRoot = GetProjectRootDirectory(testDirectory);

if (projectRoot == null)
{
throw new DirectoryNotFoundException("Could not find project root directory");
}

// Discover links from XAML files
var xamlLinks = await GetExternalLinksFromXamlFilesAsync(projectRoot);
foreach (var link in xamlLinks)
{
allLinks.Add(link);
}

// Discover links from ControlInfoData.json
var jsonLinks = await GetExternalLinksFromControlInfoDataAsync(projectRoot);
foreach (var link in jsonLinks)
{
allLinks.Add(link);
}

return allLinks.ToList();
}

/// <summary>
/// Finds the project root directory by looking for the .sln file
/// </summary>
private string GetProjectRootDirectory(string startPath)
{
var directory = new DirectoryInfo(startPath);

while (directory != null)
{
if (directory.GetFiles("*.sln").Any())
{
return directory.FullName;
}
directory = directory.Parent;
}

return null;
}

/// <summary>
/// Extracts external links from NavigateUri attributes in XAML files
/// </summary>
private async Task<List<string>> GetExternalLinksFromXamlFilesAsync(string projectRoot)
{
var links = new List<string>();

// Find all XAML files in the WinUIGallery directory
var winUIGalleryPath = Path.Combine(projectRoot, "WinUIGallery");
if (!Directory.Exists(winUIGalleryPath))
{
return links;
}

var xamlFiles = Directory.GetFiles(winUIGalleryPath, "*.xaml", SearchOption.AllDirectories);

// Regex to find NavigateUri attributes with URLs
var navigateUriRegex = new Regex(@"NavigateUri\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase);

foreach (var xamlFile in xamlFiles)
{
try
{
var content = await File.ReadAllTextAsync(xamlFile);
var matches = navigateUriRegex.Matches(content);

foreach (Match match in matches)
{
var uri = match.Groups[1].Value;
if (IsExternalLink(uri))
{
links.Add(uri);
}
}
}
catch (Exception ex)
{
// Log but don't fail for individual file reading issues
System.Diagnostics.Debug.WriteLine($"Failed to read XAML file {xamlFile}: {ex.Message}");
}
}

return links;
}

/// <summary>
/// Extracts external links from the ControlInfoData.json file
/// </summary>
private async Task<List<string>> GetExternalLinksFromControlInfoDataAsync(string projectRoot)
{
var links = new List<string>();

var controlInfoDataPath = Path.Combine(projectRoot, "WinUIGallery", "Samples", "Data", "ControlInfoData.json");

if (!File.Exists(controlInfoDataPath))
{
return links;
}

try
{
var jsonContent = await File.ReadAllTextAsync(controlInfoDataPath);
using var document = JsonDocument.Parse(jsonContent);

if (document.RootElement.TryGetProperty("Groups", out var groups))
{
foreach (var group in groups.EnumerateArray())
{
if (group.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("Docs", out var docs))
{
foreach (var doc in docs.EnumerateArray())
{
if (doc.TryGetProperty("Uri", out var uri))
{
var uriValue = uri.GetString();
if (!string.IsNullOrEmpty(uriValue) && IsExternalLink(uriValue))
{
links.Add(uriValue);
}
}
}
}
}
}
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to parse ControlInfoData.json: {ex.Message}");
}

return links;
}

/// <summary>
/// Determines if a URI is an external link that should be validated
/// </summary>
private static bool IsExternalLink(string uri)
{
if (string.IsNullOrWhiteSpace(uri))
return false;

// Consider HTTP/HTTPS links as external
if (uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return true;
}

// Consider ms-windows-store:// links as external
if (uri.StartsWith("ms-windows-store://", StringComparison.OrdinalIgnoreCase))
{
return true;
}

// Exclude internal/local URIs
if (uri.StartsWith("ms-appx://", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("ms-appdata://", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("ms-resource://", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("/", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("./", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("../", StringComparison.OrdinalIgnoreCase))
{
return false;
}

return false;
}

[TestCleanup]
public void Cleanup()
{
Expand Down