From d484ccf82ceb02fa03d38a14870559a5cc521a4f Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 19:20:42 +0800 Subject: [PATCH 001/115] Update --- AIDevGallery/AIDevGallery.csproj | 1 + .../FoundryLocalPickerView.xaml.cs | 5 +- .../FoundryLocal/FoundryCatalogModel.cs | 87 +----- .../FoundryLocal/FoundryClient.cs | 290 ++++++++---------- .../FoundryLocalModelProvider.cs | 122 ++++++-- .../Models/BaseSampleNavigationParameters.cs | 6 + Directory.Packages.props | 5 +- 7 files changed, 241 insertions(+), 275 deletions(-) diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index e19d9668..f07246a7 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -80,6 +80,7 @@ + diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs index e625f9c9..6fb45870 100644 --- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs +++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs @@ -5,6 +5,7 @@ using AIDevGallery.ExternalModelUtils.FoundryLocal; using AIDevGallery.Models; using AIDevGallery.ViewModels; +using Microsoft.AI.Foundry.Local; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System.Collections.Generic; @@ -16,7 +17,7 @@ namespace AIDevGallery.Controls.ModelPickerViews; internal record FoundryCatalogModelGroup(string Alias, string License, IEnumerable Details, IEnumerable Models); -internal record FoundryCatalogModelDetails(Runtime Runtime, long SizeInBytes); +internal record FoundryCatalogModelDetails(Runtime? Runtime, long SizeInBytes); internal record FoundryModelPair(string Name, ModelDetails ModelDetails, FoundryCatalogModel? FoundryCatalogModel); internal sealed partial class FoundryLocalPickerView : BaseModelPickerView { @@ -138,7 +139,7 @@ private void DownloadModelButton_Click(object sender, RoutedEventArgs e) internal static string GetExecutionProviderTextFromModel(ModelDetails model) { var foundryModel = model.ProviderModelDetails as FoundryCatalogModel; - if (foundryModel == null) + if (foundryModel == null || foundryModel.Runtime == null) { return string.Empty; } diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs index a8a3828b..187395b6 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs @@ -1,100 +1,21 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; +using Microsoft.AI.Foundry.Local; namespace AIDevGallery.ExternalModelUtils.FoundryLocal; -internal record PromptTemplate -{ - [JsonPropertyName("assistant")] - public string Assistant { get; init; } = default!; - - [JsonPropertyName("prompt")] - public string Prompt { get; init; } = default!; -} - -internal record Runtime -{ - [JsonPropertyName("deviceType")] - public string DeviceType { get; init; } = default!; - - [JsonPropertyName("executionProvider")] - public string ExecutionProvider { get; init; } = default!; -} - -internal record ModelSettings -{ - // The sample shows an empty array; keep it open‑ended. - [JsonPropertyName("parameters")] - public List Parameters { get; init; } = []; -} - internal record FoundryCachedModel(string Name, string? Id); internal record FoundryDownloadResult(bool Success, string? ErrorMessage); -internal record FoundryModelDownload( - string Name, - string Uri, - string Path, - string ProviderType, - PromptTemplate PromptTemplate); - -internal record FoundryDownloadBody(FoundryModelDownload Model, bool IgnorePipeReport); - internal record FoundryCatalogModel { - [JsonPropertyName("name")] public string Name { get; init; } = default!; - - [JsonPropertyName("displayName")] public string DisplayName { get; init; } = default!; - - [JsonPropertyName("providerType")] - public string ProviderType { get; init; } = default!; - - [JsonPropertyName("uri")] - public string Uri { get; init; } = default!; - - [JsonPropertyName("version")] - public string Version { get; init; } = default!; - - [JsonPropertyName("modelType")] - public string ModelType { get; init; } = default!; - - [JsonPropertyName("promptTemplate")] - public PromptTemplate PromptTemplate { get; init; } = default!; - - [JsonPropertyName("publisher")] - public string Publisher { get; init; } = default!; - - [JsonPropertyName("task")] - public string Task { get; init; } = default!; - - [JsonPropertyName("runtime")] - public Runtime Runtime { get; init; } = default!; - - [JsonPropertyName("fileSizeMb")] - public long FileSizeMb { get; init; } - - [JsonPropertyName("modelSettings")] - public ModelSettings ModelSettings { get; init; } = default!; - - [JsonPropertyName("alias")] public string Alias { get; init; } = default!; - - [JsonPropertyName("supportsToolCalling")] - public bool SupportsToolCalling { get; init; } - - [JsonPropertyName("license")] + public long FileSizeMb { get; init; } public string License { get; init; } = default!; - - [JsonPropertyName("licenseDescription")] - public string LicenseDescription { get; init; } = default!; - - [JsonPropertyName("parentModelUri")] - public string ParentModelUri { get; init; } = default!; + public string ModelId { get; init; } = default!; + public Runtime? Runtime { get; init; } } \ No newline at end of file diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index a88709ad..1482d74b 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -1,14 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.AI.Foundry.Local; +using Microsoft.Extensions.Logging.Abstractions; using System; using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -16,209 +13,190 @@ namespace AIDevGallery.ExternalModelUtils.FoundryLocal; internal class FoundryClient { + private FoundryLocalManager? _manager; + private ICatalog? _catalog; + private readonly Dictionary _preparedModels = new(); + private readonly SemaphoreSlim _prepareLock = new(1, 1); + public static async Task CreateAsync() { - var serviceManager = FoundryServiceManager.TryCreate(); - if (serviceManager == null) + try { - return null; - } + var config = new Configuration + { + AppName = "AIDevGallery", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Warning, + Web = new Configuration.WebService + { + Urls = "http://127.0.0.1:0" + } + }; - if (!await serviceManager.IsRunning()) - { - if (!await serviceManager.StartService()) + await FoundryLocalManager.CreateAsync(config, NullLogger.Instance); + + if (!FoundryLocalManager.IsInitialized) { return null; } - } - var serviceUrl = await serviceManager.GetServiceUrl(); + var client = new FoundryClient + { + _manager = FoundryLocalManager.Instance + }; + + await client._manager.EnsureEpsDownloadedAsync(); + client._catalog = await client._manager.GetCatalogAsync(); - if (string.IsNullOrEmpty(serviceUrl)) + return client; + } + catch { return null; } - - return new FoundryClient(serviceUrl, serviceManager, new HttpClient()); - } - - public FoundryServiceManager ServiceManager { get; init; } - - private HttpClient _httpClient; - private string _baseUrl; - private List _catalogModels = []; - - private FoundryClient(string baseUrl, FoundryServiceManager serviceManager, HttpClient httpClient) - { - this.ServiceManager = serviceManager; - this._baseUrl = baseUrl; - this._httpClient = httpClient; } public async Task> ListCatalogModels() { - if (_catalogModels.Count > 0) + if (_catalog == null) { - return _catalogModels; + return []; } - try + var models = await _catalog.ListModelsAsync(); + return models.Select(model => { - var response = await _httpClient.GetAsync($"{_baseUrl}/foundry/list"); - response.EnsureSuccessStatusCode(); - - var models = await JsonSerializer.DeserializeAsync( - response.Content.ReadAsStream(), - FoundryJsonContext.Default.ListFoundryCatalogModel); - - if (models != null && models.Count > 0) + var variant = model.SelectedVariant; + var info = variant.Info; + return new FoundryCatalogModel { - models.ForEach(_catalogModels.Add); - } - } - catch - { - } - - return _catalogModels; + Name = info.Name, + DisplayName = info.DisplayName ?? info.Name, + Alias = model.Alias, + FileSizeMb = info.FileSizeMb ?? 0, + License = info.License ?? string.Empty, + ModelId = variant.Id, + Runtime = info.Runtime + }; + }).ToList(); } public async Task> ListCachedModels() { - var response = await _httpClient.GetAsync($"{_baseUrl}/openai/models"); - response.EnsureSuccessStatusCode(); - - var catalogModels = await ListCatalogModels(); + if (_catalog == null) + { + return []; + } - var content = await response.Content.ReadAsStringAsync(); - var modelIds = content.Trim('[', ']').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(id => id.Trim('"')); + var cachedVariants = await _catalog.GetCachedModelsAsync(); + return cachedVariants.Select(variant => new FoundryCachedModel(variant.Info.Name, variant.Alias)).ToList(); + } - List models = []; + public async Task DownloadModel(FoundryCatalogModel catalogModel, IProgress? progress, CancellationToken cancellationToken = default) + { + if (_catalog == null) + { + return new FoundryDownloadResult(false, "Catalog not initialized"); + } - foreach (var id in modelIds) + try { - var model = catalogModels.FirstOrDefault(m => m.Name == id); - if (model != null) + var model = await _catalog.GetModelAsync(catalogModel.Alias); + if (model == null) { - models.Add(new FoundryCachedModel(id, model.Alias)); + return new FoundryDownloadResult(false, "Model not found in catalog"); } - else + + if (await model.IsCachedAsync()) { - models.Add(new FoundryCachedModel(id, null)); + await PrepareModelAsync(catalogModel.Alias, cancellationToken); + return new FoundryDownloadResult(true, "Model already downloaded"); } - } - return models; + await model.DownloadAsync( + progressPercent => + { + progress?.Report(progressPercent / 100f); + }, cancellationToken); + + await PrepareModelAsync(catalogModel.Alias, cancellationToken); + + return new FoundryDownloadResult(true, null); + } + catch (Exception e) + { + return new FoundryDownloadResult(false, e.Message); + } } - public async Task DownloadModel(FoundryCatalogModel model, IProgress? progress, CancellationToken cancellationToken = default) + /// + /// Prepares a model for use by loading it and starting the web service. + /// Should be called after download or when first accessing a cached model. + /// Thread-safe: multiple concurrent calls for the same alias will only prepare once. + /// + /// A representing the asynchronous operation. + public async Task PrepareModelAsync(string alias, CancellationToken cancellationToken = default) { - var models = await ListCachedModels(); - - if (models.Any(m => m.Name == model.Name)) + if (_preparedModels.ContainsKey(alias)) { - return new(true, "Model already downloaded"); + return; } - return await Task.Run(async () => + await _prepareLock.WaitAsync(cancellationToken); + try { - try + // Double-check pattern for thread safety + if (_preparedModels.ContainsKey(alias)) { - var uploadBody = new FoundryDownloadBody( - new FoundryModelDownload( - Name: model.Name, - Uri: model.Uri, - Path: await GetModelPath(model.Uri), // temporary - ProviderType: model.ProviderType, - PromptTemplate: model.PromptTemplate), - IgnorePipeReport: true); - - string body = JsonSerializer.Serialize( - uploadBody, - FoundryJsonContext.Default.FoundryDownloadBody); - - using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/openai/download") - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - - using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - - response.EnsureSuccessStatusCode(); - - using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var reader = new StreamReader(stream); + return; + } - string? finalJson = null; - var line = await reader.ReadLineAsync(cancellationToken); + if (_catalog == null || _manager == null) + { + throw new InvalidOperationException("Foundry Local client not initialized"); + } - while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - line = await reader.ReadLineAsync(cancellationToken); - if (line is null) - { - continue; - } - - line = line.Trim(); - - // Final response starts with '{' - if (finalJson != null || line.StartsWith('{')) - { - finalJson += line; - continue; - } - - var match = Regex.Match(line, @"\d+(\.\d+)?%"); - if (match.Success) - { - var percentage = match.Value; - if (float.TryParse(percentage.TrimEnd('%'), out float progressValue)) - { - progress?.Report(progressValue / 100); - } - } - } + // SDK automatically selects the best variant for the given alias + var model = await _catalog.GetModelAsync(alias); + if (model == null) + { + throw new InvalidOperationException($"Model with alias '{alias}' not found in catalog"); + } - // Parse closing JSON; default if malformed - var result = finalJson is not null - ? JsonSerializer.Deserialize(finalJson, FoundryJsonContext.Default.FoundryDownloadResult)! - : new FoundryDownloadResult(false, "Missing final result from server."); + if (!await model.IsLoadedAsync()) + { + await model.LoadAsync(cancellationToken); + } - return result; + if (_manager.Urls == null || _manager.Urls.Length == 0) + { + await _manager.StartWebServiceAsync(cancellationToken); } - catch (Exception e) + + var serviceUrl = _manager.Urls?.FirstOrDefault(); + if (string.IsNullOrEmpty(serviceUrl)) { - return new FoundryDownloadResult(false, e.Message); + throw new InvalidOperationException("Failed to start Foundry Local web service"); } - }); + + _preparedModels[alias] = (serviceUrl, model.Id); + } + finally + { + _prepareLock.Release(); + } } - // this is a temporary function to get the model path from the blob storage - // it will be removed once the tag is available in the list response - private async Task GetModelPath(string assetId) + /// + /// Gets the service URL and model ID for a prepared model. + /// Returns null if the model hasn't been prepared yet. + /// + public (string ServiceUrl, string ModelId)? GetPreparedModel(string alias) { - var registryUri = - $"https://eastus.api.azureml.ms/modelregistry/v1.0/registry/models/nonazureaccount?assetId={Uri.EscapeDataString(assetId)}"; - - using var resp = await _httpClient.GetAsync(registryUri); - resp.EnsureSuccessStatusCode(); - - await using var jsonStream = await resp.Content.ReadAsStreamAsync(); - var jsonRoot = await JsonDocument.ParseAsync(jsonStream); - var blobSasUri = jsonRoot.RootElement.GetProperty("blobSasUri").GetString()!; - - var uriBuilder = new UriBuilder(blobSasUri); - var existingQuery = string.IsNullOrWhiteSpace(uriBuilder.Query) - ? string.Empty - : uriBuilder.Query.TrimStart('?') + "&"; - - uriBuilder.Query = existingQuery + "restype=container&comp=list&delimiter=/"; - - var listXml = await _httpClient.GetStringAsync(uriBuilder.Uri); + return _preparedModels.TryGetValue(alias, out var info) ? info : null; + } - var match = Regex.Match(listXml, @"(.*?)\/<\/Name>"); - return match.Success ? match.Groups[1].Value : string.Empty; + public Task GetServiceUrl() + { + return Task.FromResult(_manager?.Urls?.FirstOrDefault()); } } \ No newline at end of file diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index b3fe1604..42c53c01 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -43,19 +43,51 @@ internal class FoundryLocalModelProvider : IExternalModelProvider throw new NotImplementedException(); } + private string ExtractAlias(string url) => url.Replace(UrlPrefix, string.Empty); + public IChatClient? GetIChatClient(string url) { - var modelId = url.Split('/').LastOrDefault(); + var alias = ExtractAlias(url); + + if (_foundryManager == null || string.IsNullOrEmpty(alias)) + { + throw new InvalidOperationException("Foundry Local client not initialized or invalid model alias"); + } + + // Must be prepared beforehand via EnsureModelReadyAsync to avoid deadlock + var preparedInfo = _foundryManager.GetPreparedModel(alias); + if (preparedInfo == null) + { + throw new InvalidOperationException( + $"Model '{alias}' is not ready yet. The model is being loaded in the background. Please wait a moment and try again."); + } + + var (serviceUrl, modelId) = preparedInfo.Value; + this.url = serviceUrl; + return new OpenAIClient(new ApiKeyCredential("none"), new OpenAIClientOptions { - Endpoint = new Uri($"{Url}/v1") + Endpoint = new Uri($"{this.url}/v1") }).GetChatClient(modelId).AsIChatClient(); } public string? GetIChatClientString(string url) { - var modelId = url.Split('/').LastOrDefault(); - return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{Url}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()"; + var alias = ExtractAlias(url); + + if (_foundryManager == null) + { + return null; + } + + var preparedInfo = _foundryManager.GetPreparedModel(alias); + if (preparedInfo == null) + { + return null; + } + + var (serviceUrl, modelId) = preparedInfo.Value; + return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{serviceUrl}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()"; } public async Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default) @@ -93,7 +125,6 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress downloadedModels = []; - foreach (var model in _catalogModels) + var catalogByAlias = _catalogModels.GroupBy(m => ((FoundryCatalogModel)m.ProviderModelDetails!).Alias).ToList(); + + foreach (var aliasGroup in catalogByAlias) { - var cachedModel = cachedModels.FirstOrDefault(m => m.Name == model.Name); + var firstModel = aliasGroup.First(); + var catalogModel = (FoundryCatalogModel)firstModel.ProviderModelDetails!; + var hasCachedVariant = cachedModels.Any(cm => cm.Id == catalogModel.Alias); - if (cachedModel != default) + if (hasCachedVariant) { - model.Id = $"{UrlPrefix}{cachedModel.Id}"; - downloadedModels.Add(model); - cachedModels.Remove(cachedModel); + downloadedModels.Add(firstModel); + + _ = Task.Run(async () => + { + try + { + await _foundryManager.PrepareModelAsync(catalogModel.Alias, cancelationToken); + } + catch + { + // Silently fail - user will see "not ready" error when attempting to use the model + } + }); } } - foreach (var model in cachedModels) - { - downloadedModels.Add(new ModelDetails() - { - Id = $"fl-{model.Name}", - Name = model.Name, - Url = $"{UrlPrefix}{model.Name}", - Description = $"{model.Name} running locally with Foundry Local", - HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], - SupportedOnQualcomm = true, - ProviderModelDetails = model - }); - } - _downloadedModels = downloadedModels; - - return; } private ModelDetails ToModelDetails(FoundryCatalogModel model) { + string acceleratorInfo = model.Runtime?.ExecutionProvider switch + { + "DirectML" => " (GPU)", + "QNN" => " (NPU)", + _ => string.Empty + }; + return new ModelDetails() { - Id = $"fl-{model.Name}", - Name = model.Name, - Url = $"{UrlPrefix}{model.Name}", - Description = $"{model.Alias} running locally with Foundry Local", + Id = $"fl-{model.Alias}", + Name = model.DisplayName + acceleratorInfo, + Url = $"{UrlPrefix}{model.Alias}", + Description = $"{model.DisplayName} running locally with Foundry Local", HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], Size = model.FileSizeMb * 1024 * 1024, SupportedOnQualcomm = true, @@ -173,4 +209,26 @@ public async Task IsAvailable() await InitializeAsync(); return _foundryManager != null; } + + /// + /// Ensures the model is ready to use before calling GetIChatClient. + /// This method must be called before GetIChatClient to avoid deadlock. + /// + /// A representing the asynchronous operation. + public async Task EnsureModelReadyAsync(string url, CancellationToken cancellationToken = default) + { + var alias = ExtractAlias(url); + + if (_foundryManager == null || string.IsNullOrEmpty(alias)) + { + throw new InvalidOperationException("Foundry Local client not initialized or invalid model alias"); + } + + if (_foundryManager.GetPreparedModel(alias) != null) + { + return; + } + + await _foundryManager.PrepareModelAsync(alias, cancellationToken); + } } \ No newline at end of file diff --git a/AIDevGallery/Models/BaseSampleNavigationParameters.cs b/AIDevGallery/Models/BaseSampleNavigationParameters.cs index 4231c559..0b87f4ac 100644 --- a/AIDevGallery/Models/BaseSampleNavigationParameters.cs +++ b/AIDevGallery/Models/BaseSampleNavigationParameters.cs @@ -34,6 +34,12 @@ public void NotifyCompletion() } else if (ExternalModelHelper.IsUrlFromExternalProvider(ChatClientModelPath)) { + // For FoundryLocal, ensure model is ready before calling GetIChatClient + if (ChatClientModelPath.StartsWith("fl://")) + { + await FoundryLocalModelProvider.Instance.EnsureModelReadyAsync(ChatClientModelPath, CancellationToken).ConfigureAwait(false); + } + return ExternalModelHelper.GetIChatClient(ChatClientModelPath); } diff --git a/Directory.Packages.props b/Directory.Packages.props index 731db2a7..a0610ee0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -24,8 +25,8 @@ - - + + From 83ccfcc3a851d02091e0eb632c24bfabc1ad82df Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 19:24:57 +0800 Subject: [PATCH 002/115] delete useless files --- .../FoundryLocal/FoundryJsonContext.cs | 18 ----- .../FoundryLocal/FoundryServiceManager.cs | 81 ------------------- .../ExternalModelUtils/FoundryLocal/Utils.cs | 40 --------- 3 files changed, 139 deletions(-) delete mode 100644 AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs delete mode 100644 AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs delete mode 100644 AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs deleted file mode 100644 index 5220336b..00000000 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace AIDevGallery.ExternalModelUtils.FoundryLocal; - -[JsonSourceGenerationOptions( - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - WriteIndented = false)] -[JsonSerializable(typeof(FoundryCatalogModel))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(FoundryDownloadResult))] -[JsonSerializable(typeof(FoundryDownloadBody))] -internal partial class FoundryJsonContext : JsonSerializerContext -{ -} \ No newline at end of file diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs deleted file mode 100644 index ad554056..00000000 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.Text.RegularExpressions; -using System.Threading.Tasks; - -namespace AIDevGallery.ExternalModelUtils.FoundryLocal; - -internal class FoundryServiceManager() -{ - public static FoundryServiceManager? TryCreate() - { - if (IsAvailable()) - { - return new FoundryServiceManager(); - } - - return null; - } - - private static bool IsAvailable() - { - // run "where foundry" to check if the foundry command is available - using var p = new Process(); - p.StartInfo.FileName = "where"; - p.StartInfo.Arguments = "foundry"; - p.StartInfo.RedirectStandardOutput = true; - p.StartInfo.RedirectStandardError = true; - p.StartInfo.UseShellExecute = false; - p.StartInfo.CreateNoWindow = true; - p.Start(); - p.WaitForExit(); - return p.ExitCode == 0; - } - - private string? GetUrl(string output) - { - var match = Regex.Match(output, @"https?:\/\/[^\/]+:\d+"); - if (match.Success) - { - return match.Value; - } - - return null; - } - - public async Task GetServiceUrl() - { - var status = await Utils.RunFoundryWithArguments("service status"); - - if (status.ExitCode != 0 || string.IsNullOrWhiteSpace(status.Output)) - { - return null; - } - - return GetUrl(status.Output); - } - - public async Task IsRunning() - { - var url = await GetServiceUrl(); - return url != null; - } - - public async Task StartService() - { - if (await IsRunning()) - { - return true; - } - - var status = await Utils.RunFoundryWithArguments("service start"); - if (status.ExitCode != 0 || string.IsNullOrWhiteSpace(status.Output)) - { - return false; - } - - return GetUrl(status.Output) != null; - } -} \ No newline at end of file diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs deleted file mode 100644 index 22c98504..00000000 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Diagnostics; -using System.Threading.Tasks; - -namespace AIDevGallery.ExternalModelUtils.FoundryLocal; - -internal class Utils -{ - public async static Task<(string? Output, string? Error, int ExitCode)> RunFoundryWithArguments(string arguments) - { - try - { - using (var p = new Process()) - { - p.StartInfo.FileName = "foundry"; - p.StartInfo.Arguments = arguments; - p.StartInfo.RedirectStandardOutput = true; - p.StartInfo.RedirectStandardError = true; - p.StartInfo.UseShellExecute = false; - p.StartInfo.CreateNoWindow = true; - - p.Start(); - - string output = await p.StandardOutput.ReadToEndAsync(); - string error = await p.StandardError.ReadToEndAsync(); - - await p.WaitForExitAsync(); - - return (output, error, p.ExitCode); - } - } - catch - { - return (null, null, -1); - } - } -} \ No newline at end of file From 6b70841952391abdbfaddcded9374c2a6e017fa2 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 19:40:03 +0800 Subject: [PATCH 003/115] rename --- .../FoundryLocal/FoundryCatalogModel.cs | 4 +- .../FoundryLocal/FoundryClient.cs | 10 ++-- .../FoundryLocalModelProvider.cs | 47 +++++++++---------- .../Models/BaseSampleNavigationParameters.cs | 3 +- 4 files changed, 31 insertions(+), 33 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs index 187395b6..44d3c8c5 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs @@ -5,11 +5,11 @@ namespace AIDevGallery.ExternalModelUtils.FoundryLocal; -internal record FoundryCachedModel(string Name, string? Id); +internal record FoundryCachedModelInfo(string Name, string? Id); internal record FoundryDownloadResult(bool Success, string? ErrorMessage); -internal record FoundryCatalogModel +internal record FoundryModel { public string Name { get; init; } = default!; public string DisplayName { get; init; } = default!; diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index 1482d74b..0c0af2fb 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -55,7 +55,7 @@ internal class FoundryClient } } - public async Task> ListCatalogModels() + public async Task> ListCatalogModels() { if (_catalog == null) { @@ -67,7 +67,7 @@ public async Task> ListCatalogModels() { var variant = model.SelectedVariant; var info = variant.Info; - return new FoundryCatalogModel + return new FoundryModel { Name = info.Name, DisplayName = info.DisplayName ?? info.Name, @@ -80,7 +80,7 @@ public async Task> ListCatalogModels() }).ToList(); } - public async Task> ListCachedModels() + public async Task> ListCachedModels() { if (_catalog == null) { @@ -88,10 +88,10 @@ public async Task> ListCachedModels() } var cachedVariants = await _catalog.GetCachedModelsAsync(); - return cachedVariants.Select(variant => new FoundryCachedModel(variant.Info.Name, variant.Alias)).ToList(); + return cachedVariants.Select(variant => new FoundryCachedModelInfo(variant.Info.Name, variant.Alias)).ToList(); } - public async Task DownloadModel(FoundryCatalogModel catalogModel, IProgress? progress, CancellationToken cancellationToken = default) + public async Task DownloadModel(FoundryModel catalogModel, IProgress? progress, CancellationToken cancellationToken = default) { if (_catalog == null) { diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 42c53c01..aef1bf57 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -19,7 +19,7 @@ internal class FoundryLocalModelProvider : IExternalModelProvider { private IEnumerable? _downloadedModels; private IEnumerable? _catalogModels; - private FoundryClient? _foundryManager; + private FoundryClient? _foundryClient; private string? url; public static FoundryLocalModelProvider Instance { get; } = new FoundryLocalModelProvider(); @@ -49,13 +49,13 @@ internal class FoundryLocalModelProvider : IExternalModelProvider { var alias = ExtractAlias(url); - if (_foundryManager == null || string.IsNullOrEmpty(alias)) + if (_foundryClient == null || string.IsNullOrEmpty(alias)) { throw new InvalidOperationException("Foundry Local client not initialized or invalid model alias"); } // Must be prepared beforehand via EnsureModelReadyAsync to avoid deadlock - var preparedInfo = _foundryManager.GetPreparedModel(alias); + var preparedInfo = _foundryClient.GetPreparedModel(alias); if (preparedInfo == null) { throw new InvalidOperationException( @@ -63,11 +63,10 @@ internal class FoundryLocalModelProvider : IExternalModelProvider } var (serviceUrl, modelId) = preparedInfo.Value; - this.url = serviceUrl; return new OpenAIClient(new ApiKeyCredential("none"), new OpenAIClientOptions { - Endpoint = new Uri($"{this.url}/v1") + Endpoint = new Uri($"{serviceUrl}/v1") }).GetChatClient(modelId).AsIChatClient(); } @@ -75,12 +74,12 @@ internal class FoundryLocalModelProvider : IExternalModelProvider { var alias = ExtractAlias(url); - if (_foundryManager == null) + if (_foundryClient == null) { return null; } - var preparedInfo = _foundryManager.GetPreparedModel(alias); + var preparedInfo = _foundryClient.GetPreparedModel(alias); if (preparedInfo == null) { return null; @@ -109,17 +108,17 @@ public IEnumerable GetAllModelsInCatalog() public async Task DownloadModel(ModelDetails modelDetails, IProgress? progress, CancellationToken cancellationToken = default) { - if (_foundryManager == null) + if (_foundryClient == null) { return false; } - if (modelDetails.ProviderModelDetails is not FoundryCatalogModel model) + if (modelDetails.ProviderModelDetails is not FoundryModel model) { return false; } - return (await _foundryManager.DownloadModel(model, progress, cancellationToken)).Success; + return (await _foundryClient.DownloadModel(model, progress, cancellationToken)).Success; } private void Reset() @@ -129,35 +128,35 @@ private void Reset() private async Task InitializeAsync(CancellationToken cancelationToken = default) { - if (_foundryManager != null && _downloadedModels != null && _downloadedModels.Any()) + if (_foundryClient != null && _downloadedModels != null && _downloadedModels.Any()) { return; } - _foundryManager = _foundryManager ?? await FoundryClient.CreateAsync(); + _foundryClient = _foundryClient ?? await FoundryClient.CreateAsync(); - if (_foundryManager == null) + if (_foundryClient == null) { return; } - url = url ?? await _foundryManager.GetServiceUrl(); + url = url ?? await _foundryClient.GetServiceUrl(); if (_catalogModels == null || !_catalogModels.Any()) { - _catalogModels = (await _foundryManager.ListCatalogModels()).Select(m => ToModelDetails(m)); + _catalogModels = (await _foundryClient.ListCatalogModels()).Select(m => ToModelDetails(m)); } - var cachedModels = await _foundryManager.ListCachedModels(); + var cachedModels = await _foundryClient.ListCachedModels(); List downloadedModels = []; - var catalogByAlias = _catalogModels.GroupBy(m => ((FoundryCatalogModel)m.ProviderModelDetails!).Alias).ToList(); + var catalogByAlias = _catalogModels.GroupBy(m => ((FoundryModel)m.ProviderModelDetails!).Alias).ToList(); foreach (var aliasGroup in catalogByAlias) { var firstModel = aliasGroup.First(); - var catalogModel = (FoundryCatalogModel)firstModel.ProviderModelDetails!; + var catalogModel = (FoundryModel)firstModel.ProviderModelDetails!; var hasCachedVariant = cachedModels.Any(cm => cm.Id == catalogModel.Alias); if (hasCachedVariant) @@ -168,7 +167,7 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) { try { - await _foundryManager.PrepareModelAsync(catalogModel.Alias, cancelationToken); + await _foundryClient.PrepareModelAsync(catalogModel.Alias, cancelationToken); } catch { @@ -181,7 +180,7 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) _downloadedModels = downloadedModels; } - private ModelDetails ToModelDetails(FoundryCatalogModel model) + private ModelDetails ToModelDetails(FoundryModel model) { string acceleratorInfo = model.Runtime?.ExecutionProvider switch { @@ -207,7 +206,7 @@ private ModelDetails ToModelDetails(FoundryCatalogModel model) public async Task IsAvailable() { await InitializeAsync(); - return _foundryManager != null; + return _foundryClient != null; } /// @@ -219,16 +218,16 @@ public async Task EnsureModelReadyAsync(string url, CancellationToken cancellati { var alias = ExtractAlias(url); - if (_foundryManager == null || string.IsNullOrEmpty(alias)) + if (_foundryClient == null || string.IsNullOrEmpty(alias)) { throw new InvalidOperationException("Foundry Local client not initialized or invalid model alias"); } - if (_foundryManager.GetPreparedModel(alias) != null) + if (_foundryClient.GetPreparedModel(alias) != null) { return; } - await _foundryManager.PrepareModelAsync(alias, cancellationToken); + await _foundryClient.PrepareModelAsync(alias, cancellationToken); } } \ No newline at end of file diff --git a/AIDevGallery/Models/BaseSampleNavigationParameters.cs b/AIDevGallery/Models/BaseSampleNavigationParameters.cs index 0b87f4ac..a22d5049 100644 --- a/AIDevGallery/Models/BaseSampleNavigationParameters.cs +++ b/AIDevGallery/Models/BaseSampleNavigationParameters.cs @@ -34,8 +34,7 @@ public void NotifyCompletion() } else if (ExternalModelHelper.IsUrlFromExternalProvider(ChatClientModelPath)) { - // For FoundryLocal, ensure model is ready before calling GetIChatClient - if (ChatClientModelPath.StartsWith("fl://")) + if (ChatClientHardwareAccelerator == HardwareAccelerator.FOUNDRYLOCAL) { await FoundryLocalModelProvider.Instance.EnsureModelReadyAsync(ChatClientModelPath, CancellationToken).ConfigureAwait(false); } From 2b960aa7989076594f90fafe3b243cd41e18c282 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 19:47:45 +0800 Subject: [PATCH 004/115] rename --- .../FoundryLocal/FoundryCatalogModel.cs | 2 +- .../ExternalModelUtils/FoundryLocal/FoundryClient.cs | 6 +++--- .../ExternalModelUtils/FoundryLocalModelProvider.cs | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs index 44d3c8c5..59e80d9a 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs @@ -9,7 +9,7 @@ internal record FoundryCachedModelInfo(string Name, string? Id); internal record FoundryDownloadResult(bool Success, string? ErrorMessage); -internal record FoundryModel +internal record FoundryCatalogModel { public string Name { get; init; } = default!; public string DisplayName { get; init; } = default!; diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index 0c0af2fb..db876d50 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -55,7 +55,7 @@ internal class FoundryClient } } - public async Task> ListCatalogModels() + public async Task> ListCatalogModels() { if (_catalog == null) { @@ -67,7 +67,7 @@ public async Task> ListCatalogModels() { var variant = model.SelectedVariant; var info = variant.Info; - return new FoundryModel + return new FoundryCatalogModel { Name = info.Name, DisplayName = info.DisplayName ?? info.Name, @@ -91,7 +91,7 @@ public async Task> ListCachedModels() return cachedVariants.Select(variant => new FoundryCachedModelInfo(variant.Info.Name, variant.Alias)).ToList(); } - public async Task DownloadModel(FoundryModel catalogModel, IProgress? progress, CancellationToken cancellationToken = default) + public async Task DownloadModel(FoundryCatalogModel catalogModel, IProgress? progress, CancellationToken cancellationToken = default) { if (_catalog == null) { diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index aef1bf57..662ca4cb 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -113,7 +113,7 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress downloadedModels = []; - var catalogByAlias = _catalogModels.GroupBy(m => ((FoundryModel)m.ProviderModelDetails!).Alias).ToList(); + var catalogByAlias = _catalogModels.GroupBy(m => ((FoundryCatalogModel)m.ProviderModelDetails!).Alias).ToList(); foreach (var aliasGroup in catalogByAlias) { var firstModel = aliasGroup.First(); - var catalogModel = (FoundryModel)firstModel.ProviderModelDetails!; + var catalogModel = (FoundryCatalogModel)firstModel.ProviderModelDetails!; var hasCachedVariant = cachedModels.Any(cm => cm.Id == catalogModel.Alias); if (hasCachedVariant) @@ -180,7 +180,7 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) _downloadedModels = downloadedModels; } - private ModelDetails ToModelDetails(FoundryModel model) + private ModelDetails ToModelDetails(FoundryCatalogModel model) { string acceleratorInfo = model.Runtime?.ExecutionProvider switch { From 27773b8c4130b9b6003153997c6b5439e489edfc Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 19:52:18 +0800 Subject: [PATCH 005/115] rename --- .../FoundryLocalModelProvider.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 662ca4cb..2935f97a 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -19,7 +19,7 @@ internal class FoundryLocalModelProvider : IExternalModelProvider { private IEnumerable? _downloadedModels; private IEnumerable? _catalogModels; - private FoundryClient? _foundryClient; + private FoundryClient? _foundryManager; private string? url; public static FoundryLocalModelProvider Instance { get; } = new FoundryLocalModelProvider(); @@ -49,13 +49,13 @@ internal class FoundryLocalModelProvider : IExternalModelProvider { var alias = ExtractAlias(url); - if (_foundryClient == null || string.IsNullOrEmpty(alias)) + if (_foundryManager == null || string.IsNullOrEmpty(alias)) { throw new InvalidOperationException("Foundry Local client not initialized or invalid model alias"); } // Must be prepared beforehand via EnsureModelReadyAsync to avoid deadlock - var preparedInfo = _foundryClient.GetPreparedModel(alias); + var preparedInfo = _foundryManager.GetPreparedModel(alias); if (preparedInfo == null) { throw new InvalidOperationException( @@ -74,12 +74,12 @@ internal class FoundryLocalModelProvider : IExternalModelProvider { var alias = ExtractAlias(url); - if (_foundryClient == null) + if (_foundryManager == null) { return null; } - var preparedInfo = _foundryClient.GetPreparedModel(alias); + var preparedInfo = _foundryManager.GetPreparedModel(alias); if (preparedInfo == null) { return null; @@ -108,7 +108,7 @@ public IEnumerable GetAllModelsInCatalog() public async Task DownloadModel(ModelDetails modelDetails, IProgress? progress, CancellationToken cancellationToken = default) { - if (_foundryClient == null) + if (_foundryManager == null) { return false; } @@ -118,7 +118,7 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress ToModelDetails(m)); + _catalogModels = (await _foundryManager.ListCatalogModels()).Select(m => ToModelDetails(m)); } - var cachedModels = await _foundryClient.ListCachedModels(); + var cachedModels = await _foundryManager.ListCachedModels(); List downloadedModels = []; @@ -167,7 +167,7 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) { try { - await _foundryClient.PrepareModelAsync(catalogModel.Alias, cancelationToken); + await _foundryManager.PrepareModelAsync(catalogModel.Alias, cancelationToken); } catch { @@ -206,7 +206,7 @@ private ModelDetails ToModelDetails(FoundryCatalogModel model) public async Task IsAvailable() { await InitializeAsync(); - return _foundryClient != null; + return _foundryManager != null; } /// @@ -218,16 +218,16 @@ public async Task EnsureModelReadyAsync(string url, CancellationToken cancellati { var alias = ExtractAlias(url); - if (_foundryClient == null || string.IsNullOrEmpty(alias)) + if (_foundryManager == null || string.IsNullOrEmpty(alias)) { throw new InvalidOperationException("Foundry Local client not initialized or invalid model alias"); } - if (_foundryClient.GetPreparedModel(alias) != null) + if (_foundryManager.GetPreparedModel(alias) != null) { return; } - await _foundryClient.PrepareModelAsync(alias, cancellationToken); + await _foundryManager.PrepareModelAsync(alias, cancellationToken); } } \ No newline at end of file From 5c60cf10d06fcc1c8901977e9b47cb7671871a21 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 20:00:21 +0800 Subject: [PATCH 006/115] remove acceleratorInfo --- .../ExternalModelUtils/FoundryLocalModelProvider.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 2935f97a..4bb6d469 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -182,17 +182,10 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) private ModelDetails ToModelDetails(FoundryCatalogModel model) { - string acceleratorInfo = model.Runtime?.ExecutionProvider switch - { - "DirectML" => " (GPU)", - "QNN" => " (NPU)", - _ => string.Empty - }; - return new ModelDetails() { Id = $"fl-{model.Alias}", - Name = model.DisplayName + acceleratorInfo, + Name = model.DisplayName, Url = $"{UrlPrefix}{model.Alias}", Description = $"{model.DisplayName} running locally with Foundry Local", HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], From 271f61039284a33570b71059d6a31c0126f7f38d Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 20:12:23 +0800 Subject: [PATCH 007/115] update --- .../ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs index 59e80d9a..07cae277 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs @@ -17,5 +17,5 @@ internal record FoundryCatalogModel public long FileSizeMb { get; init; } public string License { get; init; } = default!; public string ModelId { get; init; } = default!; - public Runtime? Runtime { get; init; } + public Runtime Runtime { get; init; } } \ No newline at end of file From 1199d02090881f3d8e039fe86f218ffe39b50d29 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 20:33:53 +0800 Subject: [PATCH 008/115] update --- .../ModelPickerViews/FoundryLocalPickerView.xaml | 8 ++++---- .../ModelPickerViews/FoundryLocalPickerView.xaml.cs | 8 ++++---- .../FoundryLocal/FoundryCatalogModel.cs | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml index f2919cb1..07b6e8da 100644 --- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml +++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml @@ -97,17 +97,17 @@ AutomationProperties.Name="More info" Style="{StaticResource TertiaryButtonStyle}" ToolTipService.ToolTip="More info" - Visibility="{x:Bind utils:XamlHelpers.VisibleWhenNotNull(FoundryCatalogModel)}"> + Visibility="{x:Bind utils:XamlHelpers.VisibleWhenNotNull(FoundryCatalogModel.Runtime)}"> - + - + - + diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs index 6fb45870..71ec1883 100644 --- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs +++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml.cs @@ -17,7 +17,7 @@ namespace AIDevGallery.Controls.ModelPickerViews; internal record FoundryCatalogModelGroup(string Alias, string License, IEnumerable Details, IEnumerable Models); -internal record FoundryCatalogModelDetails(Runtime? Runtime, long SizeInBytes); +internal record FoundryCatalogModelDetails(Runtime Runtime, long SizeInBytes); internal record FoundryModelPair(string Name, ModelDetails ModelDetails, FoundryCatalogModel? FoundryCatalogModel); internal sealed partial class FoundryLocalPickerView : BaseModelPickerView { @@ -81,7 +81,7 @@ public override async Task Load(List types) CatalogModels.Add(new FoundryCatalogModelGroup( m.Key, firstModel!.License.ToLowerInvariant(), - m.Select(m => new FoundryCatalogModelDetails(m.Runtime, m.FileSizeMb * 1024 * 1024)), + m.Where(m => m.Runtime != null).Select(m => new FoundryCatalogModelDetails(m.Runtime!, m.FileSizeMb * 1024 * 1024)), m.Where(m => !AvailableModels.Any(cm => cm.ModelDetails.Name == m.Name)) .Select(m => new DownloadableModel(catalogModelsDict[m.Name])))); } @@ -147,11 +147,11 @@ internal static string GetExecutionProviderTextFromModel(ModelDetails model) return $"Download {GetShortExectionProvider(foundryModel.Runtime.ExecutionProvider)} variant"; } - internal static string GetShortExectionProvider(string provider) + internal static string GetShortExectionProvider(string? provider) { if (string.IsNullOrWhiteSpace(provider)) { - return provider; + return string.Empty; } var shortprovider = provider.Split( diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs index 07cae277..59e80d9a 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs @@ -17,5 +17,5 @@ internal record FoundryCatalogModel public long FileSizeMb { get; init; } public string License { get; init; } = default!; public string ModelId { get; init; } = default!; - public Runtime Runtime { get; init; } + public Runtime? Runtime { get; init; } } \ No newline at end of file From 98196fb6e3ef3abefb44c4bebef700f84eddec5b Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 22:24:36 +0800 Subject: [PATCH 009/115] test nuget source --- nuget.config | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nuget.config b/nuget.config index fad1c265..07704bc6 100644 --- a/nuget.config +++ b/nuget.config @@ -1,5 +1,9 @@  + + + + From 90eb03f57e5d84958b6c60734d1bcf4b7fb4aa56 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 22:29:33 +0800 Subject: [PATCH 010/115] test nuget source --- nuget.config | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/nuget.config b/nuget.config index 07704bc6..bafe5399 100644 --- a/nuget.config +++ b/nuget.config @@ -2,9 +2,15 @@ - + + - - - + + + + + + + + \ No newline at end of file From 970ab4d060275c7cb45f7f9e4a1e1b7457207d00 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 12 Dec 2025 23:55:24 +0800 Subject: [PATCH 011/115] fix format --- .../ExternalModelUtils/FoundryLocal/FoundryClient.cs | 12 +++++++----- .../ExternalModelUtils/FoundryLocalModelProvider.cs | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index db876d50..5e943132 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -13,10 +13,10 @@ namespace AIDevGallery.ExternalModelUtils.FoundryLocal; internal class FoundryClient { - private FoundryLocalManager? _manager; - private ICatalog? _catalog; private readonly Dictionary _preparedModels = new(); private readonly SemaphoreSlim _prepareLock = new(1, 1); + private FoundryLocalManager? _manager; + private ICatalog? _catalog; public static async Task CreateAsync() { @@ -114,9 +114,10 @@ public async Task DownloadModel(FoundryCatalogModel catal await model.DownloadAsync( progressPercent => - { - progress?.Report(progressPercent / 100f); - }, cancellationToken); + { + progress?.Report(progressPercent / 100f); + }, + cancellationToken); await PrepareModelAsync(catalogModel.Alias, cancellationToken); @@ -190,6 +191,7 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation /// Gets the service URL and model ID for a prepared model. /// Returns null if the model hasn't been prepared yet. /// + /// A tuple containing the service URL and model ID, or null if not prepared. public (string ServiceUrl, string ModelId)? GetPreparedModel(string alias) { return _preparedModels.TryGetValue(alias, out var info) ? info : null; diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 4bb6d469..cc57defb 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -173,7 +173,7 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) { // Silently fail - user will see "not ready" error when attempting to use the model } - }); + }, cancelationToken); } } From ef5c00bbabd81b738e63302bbdc01a0f04a8ff99 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 00:34:07 +0800 Subject: [PATCH 012/115] fix format --- AIDevGallery/AIDevGallery.csproj | 2 ++ .../FoundryLocalModelProvider.cs | 24 ++++++++++--------- Directory.Build.props | 3 +++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index f07246a7..e7256b50 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -17,6 +17,8 @@ $(NoWarn);IL2050 $(NoWarn);IL2026 + + $(NoWarn);IDISP001;IDISP002;IDISP003;IDISP004;IDISP006;IDISP007;IDISP008;IDISP017;IDISP025 true SamplesRoots.xml diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index cc57defb..a3e444a5 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -163,17 +163,19 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) { downloadedModels.Add(firstModel); - _ = Task.Run(async () => - { - try + _ = Task.Run( + async () => { - await _foundryManager.PrepareModelAsync(catalogModel.Alias, cancelationToken); - } - catch - { - // Silently fail - user will see "not ready" error when attempting to use the model - } - }, cancelationToken); + try + { + await _foundryManager.PrepareModelAsync(catalogModel.Alias, cancelationToken); + } + catch + { + // Silently fail - user will see "not ready" error when attempting to use the model + } + }, + cancelationToken); } } @@ -182,7 +184,7 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) private ModelDetails ToModelDetails(FoundryCatalogModel model) { - return new ModelDetails() + return new ModelDetails { Id = $"fl-{model.Alias}", Name = model.DisplayName, diff --git a/Directory.Build.props b/Directory.Build.props index 0f9c7d8e..18aaea7e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,6 +11,9 @@ Recommended true true + + + $(NoWarn);IDISP001;IDISP003;IDISP017 true From 378100c13fc11c1d2b1af60e7fe0962ec021244b Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 00:48:48 +0800 Subject: [PATCH 013/115] CI/CD error --- AIDevGallery/AIDevGallery.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index e7256b50..2eea060f 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -354,6 +354,11 @@ + + + + + Designer From 6811b13572d3d7f1e8bad33ebd856ad9db668672 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 00:53:05 +0800 Subject: [PATCH 014/115] CI/CD error --- AIDevGallery/AIDevGallery.csproj | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index 2eea060f..51607e2e 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -355,9 +355,11 @@ - - - + + + + + From 061806edd9d3c0b1231a837b419e2a227f527c09 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 00:54:35 +0800 Subject: [PATCH 015/115] CI/CD error --- AIDevGallery/AIDevGallery.csproj | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index 51607e2e..a54b54aa 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -354,12 +354,10 @@ - - - - - - + + + + From 2e5bcf190204b107af738d6beaa5a32d16c99900 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 01:04:42 +0800 Subject: [PATCH 016/115] Fix APPX1101 duplicate onnxruntime.dll error by adopting Foundry Local SDK's ExcludeExtraLibs pattern --- AIDevGallery/AIDevGallery.csproj | 6 ++--- AIDevGallery/ExcludeExtraLibs.props | 35 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 AIDevGallery/ExcludeExtraLibs.props diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index a54b54aa..affca975 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -354,10 +354,8 @@ - - - - + + diff --git a/AIDevGallery/ExcludeExtraLibs.props b/AIDevGallery/ExcludeExtraLibs.props new file mode 100644 index 00000000..f8e74f0f --- /dev/null +++ b/AIDevGallery/ExcludeExtraLibs.props @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + From 32b52a71bc80387753927919cfc4bc1dba08f426 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 01:23:36 +0800 Subject: [PATCH 017/115] CI/CD error --- AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj b/AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj index 4797744b..8efd3f1a 100644 --- a/AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj +++ b/AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj @@ -12,7 +12,7 @@ true enable false - CS1591 + CS1591;IDISP001;IDISP002;IDISP003;IDISP004;IDISP006;IDISP007;IDISP008;IDISP017;IDISP025 Recommended false From e555a5108c20e17102d7847a18e8f530ea1fd845 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 02:52:11 +0800 Subject: [PATCH 018/115] Add telemetry --- .../FoundryLocalModelProvider.cs | 8 +++- .../Events/FoundryLocalDownloadEvent.cs | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index a3e444a5..c7e86ef5 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -3,6 +3,7 @@ using AIDevGallery.ExternalModelUtils.FoundryLocal; using AIDevGallery.Models; +using AIDevGallery.Telemetry.Events; using AIDevGallery.Utils; using Microsoft.Extensions.AI; using OpenAI; @@ -118,7 +119,12 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress PrivTags.ProductAndServiceUsage; + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + } + + public static void Log(string modelAlias, bool success, string? errorMessage = null) + { + TelemetryFactory.Get().Log( + "FoundryLocalDownload_Event", + success ? LogLevel.Critical : LogLevel.Error, + new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, DateTime.Now)); + } +} From 4733634ff925d809cfacaba1d3a1b478e7669dcd Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 02:54:17 +0800 Subject: [PATCH 019/115] Add telemetry --- .../Events/FoundryLocalDownloadEvent.cs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs b/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs index 05ec3c3c..17b601ac 100644 --- a/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs +++ b/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs @@ -35,9 +35,21 @@ public override void ReplaceSensitiveStrings(Func replaceSensi public static void Log(string modelAlias, bool success, string? errorMessage = null) { - TelemetryFactory.Get().Log( - "FoundryLocalDownload_Event", - success ? LogLevel.Critical : LogLevel.Error, - new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, DateTime.Now)); + if (success) + { + TelemetryFactory.Get().Log( + "FoundryLocalDownload_Event", + LogLevel.Critical, + new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, DateTime.Now)); + } + else + { + var relatedActivityId = Guid.NewGuid(); + TelemetryFactory.Get().LogError( + "FoundryLocalDownload_Event", + LogLevel.Critical, + new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, DateTime.Now), + relatedActivityId); + } } } From a024a976d7a74b28df24aeb2c45782928510c672 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Sat, 13 Dec 2025 12:14:44 +0800 Subject: [PATCH 020/115] format --- .../ExternalModelUtils/FoundryLocalModelProvider.cs | 6 +++--- AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index c7e86ef5..a8049ed9 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using AIDevGallery.ExternalModelUtils.FoundryLocal; @@ -120,10 +120,10 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress Date: Sun, 14 Dec 2025 02:12:32 +0800 Subject: [PATCH 021/115] chatClient use SDK --- .../FoundryLocal/FoundryClient.cs | 26 ++--- .../FoundryLocalChatClientAdapter.cs | 96 +++++++++++++++++++ .../FoundryLocalModelProvider.cs | 36 ++----- 3 files changed, 113 insertions(+), 45 deletions(-) create mode 100644 AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index 5e943132..6872c8cf 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -13,7 +13,7 @@ namespace AIDevGallery.ExternalModelUtils.FoundryLocal; internal class FoundryClient { - private readonly Dictionary _preparedModels = new(); + private readonly Dictionary _preparedModels = new(); private readonly SemaphoreSlim _prepareLock = new(1, 1); private FoundryLocalManager? _manager; private ICatalog? _catalog; @@ -130,7 +130,7 @@ await model.DownloadAsync( } /// - /// Prepares a model for use by loading it and starting the web service. + /// Prepares a model for use by loading it (no web service needed). /// Should be called after download or when first accessing a cached model. /// Thread-safe: multiple concurrent calls for the same alias will only prepare once. /// @@ -168,18 +168,8 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation await model.LoadAsync(cancellationToken); } - if (_manager.Urls == null || _manager.Urls.Length == 0) - { - await _manager.StartWebServiceAsync(cancellationToken); - } - - var serviceUrl = _manager.Urls?.FirstOrDefault(); - if (string.IsNullOrEmpty(serviceUrl)) - { - throw new InvalidOperationException("Failed to start Foundry Local web service"); - } - - _preparedModels[alias] = (serviceUrl, model.Id); + // Store the model directly - no web service needed + _preparedModels[alias] = model; } finally { @@ -188,13 +178,13 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation } /// - /// Gets the service URL and model ID for a prepared model. + /// Gets the prepared model. /// Returns null if the model hasn't been prepared yet. /// - /// A tuple containing the service URL and model ID, or null if not prepared. - public (string ServiceUrl, string ModelId)? GetPreparedModel(string alias) + /// The IModel instance, or null if not prepared. + public IModel? GetPreparedModel(string alias) { - return _preparedModels.TryGetValue(alias, out var info) ? info : null; + return _preparedModels.TryGetValue(alias, out var model) ? model : null; } public Task GetServiceUrl() diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs new file mode 100644 index 00000000..64becc1e --- /dev/null +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.AI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace AIDevGallery.ExternalModelUtils.FoundryLocal; + +/// +/// Adapter that wraps FoundryLocal SDK's native OpenAIChatClient to work with Microsoft.Extensions.AI.IChatClient. +/// Uses the SDK's direct model API (no web service) to avoid SSE compatibility issues. +/// +internal class FoundryLocalChatClientAdapter : IChatClient +{ + private readonly Microsoft.AI.Foundry.Local.OpenAIChatClient _chatClient; + private readonly string _modelId; + + public FoundryLocalChatClientAdapter(Microsoft.AI.Foundry.Local.OpenAIChatClient chatClient, string modelId) + { + _modelId = modelId; + _chatClient = chatClient; + + // CRITICAL: MaxTokens must be set, otherwise the model won't generate any output + if (_chatClient.Settings.MaxTokens == null) + { + _chatClient.Settings.MaxTokens = 512; + } + + if (_chatClient.Settings.Temperature == null) + { + _chatClient.Settings.Temperature = 0.7f; + } + } + + public ChatClientMetadata Metadata => new("FoundryLocal", new Uri($"foundrylocal:///{_modelId}"), _modelId); + + public Task GetResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + GetStreamingResponseAsync(chatMessages, options, cancellationToken).ToChatResponseAsync(cancellationToken: cancellationToken); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var messageList = chatMessages.ToList(); + var openAIMessages = ConvertToFoundryMessages(messageList); + + // Use FoundryLocal SDK's native streaming API - direct in-memory communication, no HTTP/SSE + var streamingResponse = _chatClient.CompleteChatStreamingAsync(openAIMessages, cancellationToken); + + string responseId = Guid.NewGuid().ToString("N"); + await foreach (var chunk in streamingResponse) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (chunk.Choices != null && chunk.Choices.Count > 0) + { + var content = chunk.Choices[0].Message?.Content; + if (!string.IsNullOrEmpty(content)) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, content) + { + ResponseId = responseId + }; + } + } + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceType?.IsInstanceOfType(this) == true ? this : null; + } + + public void Dispose() + { + // ChatClient doesn't need disposal + } + + private static List ConvertToFoundryMessages(IList messages) + { + return messages.Select(m => new Betalgo.Ranul.OpenAI.ObjectModels.RequestModels.ChatMessage + { + Role = m.Role.Value, + Content = m.Text ?? string.Empty + }).ToList(); + } +} \ No newline at end of file diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index a8049ed9..deb6ec46 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -6,9 +6,7 @@ using AIDevGallery.Telemetry.Events; using AIDevGallery.Utils; using Microsoft.Extensions.AI; -using OpenAI; using System; -using System.ClientModel; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -56,19 +54,18 @@ internal class FoundryLocalModelProvider : IExternalModelProvider } // Must be prepared beforehand via EnsureModelReadyAsync to avoid deadlock - var preparedInfo = _foundryManager.GetPreparedModel(alias); - if (preparedInfo == null) + var model = _foundryManager.GetPreparedModel(alias); + if (model == null) { throw new InvalidOperationException( $"Model '{alias}' is not ready yet. The model is being loaded in the background. Please wait a moment and try again."); } - var (serviceUrl, modelId) = preparedInfo.Value; + // Get the native FoundryLocal chat client - direct SDK usage, no web service needed + var chatClient = model.GetChatClientAsync().Result; - return new OpenAIClient(new ApiKeyCredential("none"), new OpenAIClientOptions - { - Endpoint = new Uri($"{serviceUrl}/v1") - }).GetChatClient(modelId).AsIChatClient(); + // Wrap it in our adapter to implement IChatClient interface + return new FoundryLocal.FoundryLocalChatClientAdapter(chatClient, model.Id); } public string? GetIChatClientString(string url) @@ -80,14 +77,13 @@ internal class FoundryLocalModelProvider : IExternalModelProvider return null; } - var preparedInfo = _foundryManager.GetPreparedModel(alias); - if (preparedInfo == null) + var model = _foundryManager.GetPreparedModel(alias); + if (model == null) { return null; } - var (serviceUrl, modelId) = preparedInfo.Value; - return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{serviceUrl}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()"; + return $"var model = await catalog.GetModelAsync(\"{alias}\"); await model.LoadAsync(); var chatClient = await model.GetChatClientAsync(); /* Use chatClient.CompleteChatStreamingAsync() */"; } public async Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default) @@ -168,20 +164,6 @@ private async Task InitializeAsync(CancellationToken cancelationToken = default) if (hasCachedVariant) { downloadedModels.Add(firstModel); - - _ = Task.Run( - async () => - { - try - { - await _foundryManager.PrepareModelAsync(catalogModel.Alias, cancelationToken); - } - catch - { - // Silently fail - user will see "not ready" error when attempting to use the model - } - }, - cancelationToken); } } From ecb30f7f419559d2fc848a1c20a249b515cd6efa Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Mon, 15 Dec 2025 14:36:27 +0800 Subject: [PATCH 022/115] Fix --- .../FoundryLocal/FoundryClient.cs | 19 ++++++++++++++- .../FoundryLocalModelProvider.cs | 24 ++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index 6872c8cf..59aa5a8e 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -11,12 +11,13 @@ namespace AIDevGallery.ExternalModelUtils.FoundryLocal; -internal class FoundryClient +internal class FoundryClient : IDisposable { private readonly Dictionary _preparedModels = new(); private readonly SemaphoreSlim _prepareLock = new(1, 1); private FoundryLocalManager? _manager; private ICatalog? _catalog; + private bool _disposed; public static async Task CreateAsync() { @@ -191,4 +192,20 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation { return Task.FromResult(_manager?.Urls?.FirstOrDefault()); } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _prepareLock.Dispose(); + + // Models are managed by FoundryLocalManager and should not be disposed here + // The FoundryLocalManager instance is a singleton and manages its own lifecycle + _preparedModels.Clear(); + + _disposed = true; + } } \ No newline at end of file diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index deb6ec46..32557677 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -27,7 +27,7 @@ internal class FoundryLocalModelProvider : IExternalModelProvider public HardwareAccelerator ModelHardwareAccelerator => HardwareAccelerator.FOUNDRYLOCAL; - public List NugetPackageReferences => ["Microsoft.Extensions.AI.OpenAI"]; + public List NugetPackageReferences => ["Microsoft.AI.Foundry.Local.WinML", "Microsoft.Extensions.AI"]; public string ProviderDescription => "The model will run locally via Foundry Local"; @@ -62,7 +62,8 @@ internal class FoundryLocalModelProvider : IExternalModelProvider } // Get the native FoundryLocal chat client - direct SDK usage, no web service needed - var chatClient = model.GetChatClientAsync().Result; + // Note: This synchronous wrapper is safe here because the model is already prepared/loaded + var chatClient = model.GetChatClientAsync().ConfigureAwait(false).GetAwaiter().GetResult(); // Wrap it in our adapter to implement IChatClient interface return new FoundryLocal.FoundryLocalChatClientAdapter(chatClient, model.Id); @@ -83,7 +84,24 @@ internal class FoundryLocalModelProvider : IExternalModelProvider return null; } - return $"var model = await catalog.GetModelAsync(\"{alias}\"); await model.LoadAsync(); var chatClient = await model.GetChatClientAsync(); /* Use chatClient.CompleteChatStreamingAsync() */"; + return $@"// Initialize Foundry Local +var config = new Configuration {{ AppName = ""YourApp"", LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Warning }}; +await FoundryLocalManager.CreateAsync(config, NullLogger.Instance); +var manager = FoundryLocalManager.Instance; +var catalog = await manager.GetCatalogAsync(); + +// Get and load the model +var model = await catalog.GetModelAsync(""{alias}""); +await model.LoadAsync(); + +// Get chat client and use it +var chatClient = await model.GetChatClientAsync(); +var messages = new List {{ new(""user"", ""Your message here"") }}; +await foreach (var chunk in chatClient.CompleteChatStreamingAsync(messages)) +{{ + // Process streaming response + Console.Write(chunk.Choices[0].Message?.Content); +}}"; } public async Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default) From bc8c4989bb75ef61bd1529e3755f2e4998167874 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Mon, 15 Dec 2025 14:48:18 +0800 Subject: [PATCH 023/115] Fix --- .../FoundryLocal/FoundryLocalChatClientAdapter.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index 64becc1e..7e80fb7d 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -28,7 +28,7 @@ public FoundryLocalChatClientAdapter(Microsoft.AI.Foundry.Local.OpenAIChatClient // CRITICAL: MaxTokens must be set, otherwise the model won't generate any output if (_chatClient.Settings.MaxTokens == null) { - _chatClient.Settings.MaxTokens = 512; + _chatClient.Settings.MaxTokens = 1024; } if (_chatClient.Settings.Temperature == null) @@ -52,8 +52,6 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { var messageList = chatMessages.ToList(); var openAIMessages = ConvertToFoundryMessages(messageList); - - // Use FoundryLocal SDK's native streaming API - direct in-memory communication, no HTTP/SSE var streamingResponse = _chatClient.CompleteChatStreamingAsync(openAIMessages, cancellationToken); string responseId = Guid.NewGuid().ToString("N"); From 76b78cd226d258af0a9c05e11d874c6c7cf7d384 Mon Sep 17 00:00:00 2001 From: Milly Wei Date: Mon, 15 Dec 2025 18:55:25 +0800 Subject: [PATCH 024/115] UPDATE --- .../FoundryLocalChatClientAdapter.cs | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index 7e80fb7d..275950a9 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -24,17 +24,6 @@ public FoundryLocalChatClientAdapter(Microsoft.AI.Foundry.Local.OpenAIChatClient { _modelId = modelId; _chatClient = chatClient; - - // CRITICAL: MaxTokens must be set, otherwise the model won't generate any output - if (_chatClient.Settings.MaxTokens == null) - { - _chatClient.Settings.MaxTokens = 1024; - } - - if (_chatClient.Settings.Temperature == null) - { - _chatClient.Settings.Temperature = 0.7f; - } } public ChatClientMetadata Metadata => new("FoundryLocal", new Uri($"foundrylocal:///{_modelId}"), _modelId); @@ -50,6 +39,40 @@ public async IAsyncEnumerable GetStreamingResponseAsync( ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + // Map ChatOptions to FoundryLocal ChatSettings + // CRITICAL: MaxTokens must be set, otherwise some model won't generate any output + _chatClient.Settings.MaxTokens = options?.MaxOutputTokens ?? 1024; + + if (options?.Temperature != null) + { + _chatClient.Settings.Temperature = (float)options.Temperature; + } + + if (options?.TopP != null) + { + _chatClient.Settings.TopP = (float)options.TopP; + } + + if (options?.TopK != null) + { + _chatClient.Settings.TopK = options.TopK; + } + + if (options?.FrequencyPenalty != null) + { + _chatClient.Settings.FrequencyPenalty = (float)options.FrequencyPenalty; + } + + if (options?.PresencePenalty != null) + { + _chatClient.Settings.PresencePenalty = (float)options.PresencePenalty; + } + + if (options?.Seed != null) + { + _chatClient.Settings.RandomSeed = (int)options.Seed; + } + var messageList = chatMessages.ToList(); var openAIMessages = ConvertToFoundryMessages(messageList); var streamingResponse = _chatClient.CompleteChatStreamingAsync(openAIMessages, cancellationToken); From 649d8cb9a50d9acc623b3055463f02525bd759dd Mon Sep 17 00:00:00 2001 From: Milly Wei Date: Mon, 15 Dec 2025 19:04:14 +0800 Subject: [PATCH 025/115] UPDATE --- .../Samples/Open Source Models/Language Models/Generate.xaml.cs | 2 +- .../Open Source Models/Language Models/GenerateCode.xaml.cs | 2 +- .../Open Source Models/Language Models/Paraphrase.xaml.cs | 2 +- .../Open Source Models/Language Models/Summarize.xaml.cs | 2 +- .../Open Source Models/Language Models/Translate.xaml.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs index b8629b94..21519aad 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/Generate.xaml.cs @@ -121,7 +121,7 @@ public void GenerateText(string topic) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) ], - null, + new() { MaxOutputTokens = _maxTokenLength }, cts.Token)) { // diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs index ce6f8436..2125cc85 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs @@ -118,7 +118,7 @@ public void GenerateSolution(string problem, string currentLanguage) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, problem) ], - null, + new() { MaxOutputTokens = _defaultMaxLength }, cts.Token)) { generatedCode += messagePart; diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs index 998c915e..02307ca7 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs @@ -107,7 +107,7 @@ public void ParaphraseText(string text) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) ], - null, + new() { MaxOutputTokens = _defaultMaxLength }, cts.Token)) { DispatcherQueue.TryEnqueue(() => diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs index a43b1829..26190a42 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs @@ -103,7 +103,7 @@ public void SummarizeText(string text) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) ], - null, + new() { MaxOutputTokens = _defaultMaxLength }, cts.Token)) { DispatcherQueue.TryEnqueue(() => diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs index 52438fac..d34bfc1f 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs @@ -110,7 +110,7 @@ public void TranslateText(string text) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) ], - null, + new() { MaxOutputTokens = _defaultMaxLength }, cts.Token)) { DispatcherQueue.TryEnqueue(() => From 05fe712ca6263bbba2d4fe78fbff6ddbf7417905 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Mon, 15 Dec 2025 19:52:05 +0800 Subject: [PATCH 026/115] revert --- .../Open Source Models/Language Models/GenerateCode.xaml.cs | 2 +- .../Open Source Models/Language Models/Paraphrase.xaml.cs | 2 +- .../Open Source Models/Language Models/Summarize.xaml.cs | 2 +- .../Open Source Models/Language Models/Translate.xaml.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs index 2125cc85..ce6f8436 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/GenerateCode.xaml.cs @@ -118,7 +118,7 @@ public void GenerateSolution(string problem, string currentLanguage) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, problem) ], - new() { MaxOutputTokens = _defaultMaxLength }, + null, cts.Token)) { generatedCode += messagePart; diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs index 02307ca7..998c915e 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/Paraphrase.xaml.cs @@ -107,7 +107,7 @@ public void ParaphraseText(string text) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) ], - new() { MaxOutputTokens = _defaultMaxLength }, + null, cts.Token)) { DispatcherQueue.TryEnqueue(() => diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs index 26190a42..a43b1829 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/Summarize.xaml.cs @@ -103,7 +103,7 @@ public void SummarizeText(string text) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) ], - new() { MaxOutputTokens = _defaultMaxLength }, + null, cts.Token)) { DispatcherQueue.TryEnqueue(() => diff --git a/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs b/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs index d34bfc1f..52438fac 100644 --- a/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs +++ b/AIDevGallery/Samples/Open Source Models/Language Models/Translate.xaml.cs @@ -110,7 +110,7 @@ public void TranslateText(string text) new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) ], - new() { MaxOutputTokens = _defaultMaxLength }, + null, cts.Token)) { DispatcherQueue.TryEnqueue(() => From f88bdb1a66061838832281dc9627c88bf711f413 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Mon, 15 Dec 2025 19:56:50 +0800 Subject: [PATCH 027/115] update --- AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs b/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs index 43e4fdea..457ada06 100644 --- a/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs +++ b/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs @@ -39,7 +39,7 @@ public static void Log(string modelAlias, bool success, string? errorMessage = n { TelemetryFactory.Get().Log( "FoundryLocalDownload_Event", - LogLevel.Critical, + LogLevel.Info, new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, DateTime.Now)); } else From e9aa10e983b08c6a682ac75a1189174c010c0dc4 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Mon, 15 Dec 2025 20:08:08 +0800 Subject: [PATCH 028/115] update --- .../FoundryLocal/FoundryLocalChatClientAdapter.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index 275950a9..9226247e 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -17,6 +17,8 @@ namespace AIDevGallery.ExternalModelUtils.FoundryLocal; /// internal class FoundryLocalChatClientAdapter : IChatClient { + private const int DefaultMaxTokens = 1024; + private readonly Microsoft.AI.Foundry.Local.OpenAIChatClient _chatClient; private readonly string _modelId; @@ -41,7 +43,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { // Map ChatOptions to FoundryLocal ChatSettings // CRITICAL: MaxTokens must be set, otherwise some model won't generate any output - _chatClient.Settings.MaxTokens = options?.MaxOutputTokens ?? 1024; + _chatClient.Settings.MaxTokens = options?.MaxOutputTokens ?? DefaultMaxTokens; if (options?.Temperature != null) { From b5ca5b6a3209f11f7a1d4fd89643fe60b761f00b Mon Sep 17 00:00:00 2001 From: MillyWei Date: Mon, 15 Dec 2025 20:51:34 +0800 Subject: [PATCH 029/115] fix format --- .../FoundryLocalChatClientAdapter.cs | 2 +- PR_FoundryLocal_SDK_Migration.md | 347 ++++++++++++++++++ 2 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 PR_FoundryLocal_SDK_Migration.md diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index 9226247e..98c28313 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -44,7 +44,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( // Map ChatOptions to FoundryLocal ChatSettings // CRITICAL: MaxTokens must be set, otherwise some model won't generate any output _chatClient.Settings.MaxTokens = options?.MaxOutputTokens ?? DefaultMaxTokens; - + if (options?.Temperature != null) { _chatClient.Settings.Temperature = (float)options.Temperature; diff --git a/PR_FoundryLocal_SDK_Migration.md b/PR_FoundryLocal_SDK_Migration.md new file mode 100644 index 00000000..0a13fd82 --- /dev/null +++ b/PR_FoundryLocal_SDK_Migration.md @@ -0,0 +1,347 @@ +# Fix production-breaking bug: Migrate FoundryLocal integration to official SDK (v0.8.2.1) + +## **Summary** + +This PR resolves a **critical production-breaking bug** that blocked all FoundryLocal model downloads in AI Dev Gallery after upgrading to **Foundry Local v0.8.x+**. We migrate from fragile custom HTTP API calls to the **official `Microsoft.AI.Foundry.Local.WinML` SDK (v0.8.2.1)**, restoring full functionality and establishing a resilient foundation for future compatibility. + +**Impact:** Users can now reliably download, prepare, and use FoundryLocal models. The system is significantly more robust against upstream API changes. + +--- + +## **Background & Root Cause** + +In **July 2024**, Foundry Local changed the internal format of a critical **`Name`** field in its HTTP API response. While this change was handled internally by Foundry Local, it was **not communicated externally**, causing silent incompatibility with AIDG's direct HTTP-based integration. + +**After upgrading to Foundry Local v0.8.x:** +- All model download requests **failed silently** due to field format mismatch +- Downstream workflows (model preparation → chat/inference) were **completely blocked** +- Users experienced **inability to download or use any FoundryLocal models** + +**Business Impact:** +- Disrupted critical developer workflows +- Increased support burden and user frustration +- Eroded trust in AIDG's stability and reliability + +--- + +## **Solution: SDK Migration** + +To eliminate this entire class of failures and future-proof the integration, we **migrate to the official SDK**: + +### **Key Benefits:** +1. **API Stability**: SDK shields us from low-level HTTP API format changes +2. **Versioned Support**: Official, stable integration points maintained by Foundry Local team +3. **Simplified Architecture**: Cleaner code with built-in concurrency and error handling +4. **Direct Model Access**: No web service dependency—models run directly via SDK + +--- + +## **Technical Changes** + +### **1. Core Architecture Refactor** + +#### **`FoundryClient.cs` (297 lines changed)** +- **Before**: Custom HTTP client with manual JSON parsing, regex-based progress tracking, and fragile blob storage path resolution +- **After**: Official SDK usage via `FoundryLocalManager`, `ICatalog`, and `IModel` interfaces +- **Key Improvements**: + - Thread-safe model preparation with semaphore-based locking + - Eliminated ~150 lines of error-prone HTTP/JSON handling code + - Proper async/await patterns throughout + - Implements `IDisposable` for proper resource cleanup + +#### **`FoundryLocalModelProvider.cs` (138 lines changed)** +- **Before**: Direct dependency on `OpenAI` SDK with custom service URL construction +- **After**: SDK-based model lifecycle management (catalog → download → prepare → use) +- **Key Changes**: + - Introduced `EnsureModelReadyAsync()` to prevent deadlocks in synchronous contexts + - Changed model identification from unstable `Name` field to stable `Alias` field + - Added proper model state management (`_preparedModels` dictionary) + - Integrated telemetry for download success/failure tracking + +#### **`FoundryLocalChatClientAdapter.cs` (119 new lines)** +- **Purpose**: Bridge between FoundryLocal SDK's native chat client and `Microsoft.Extensions.AI.IChatClient` interface +- **Key Features**: + - Direct SDK model access (no web service needed) + - Proper streaming response handling + - Complete `ChatOptions` parameter mapping (temperature, top-p, frequency penalty, etc.) + - **Critical Fix**: Sets `MaxTokens` default (1024) to prevent empty model outputs + +### **2. Dependency Updates** + +#### **`Directory.Packages.props`** +- ✅ Added: `Microsoft.AI.Foundry.Local.WinML` (v0.8.2.1) +- ⬆️ Upgraded: `Microsoft.ML.OnnxRuntimeGenAI.Managed` & `.WinML` (v0.10.1 → v0.11.4) + +#### **`nuget.config`** +- Added ORT package source: `https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/ORT/nuget/v3/index.json` +- Configured package source mapping to route `*Foundry*` packages to ORT feed + +### **3. Build Configuration** + +#### **`ExcludeExtraLibs.props` (35 new lines)** +- Resolves APPX1101 duplicate DLL errors by excluding conflicting ONNX Runtime libraries +- Removes CUDA provider libraries on x64 (except TRT dependencies) +- Removes QNN provider libraries on ARM64 +- Adopted from official Foundry Local SDK build patterns + +#### **`Directory.Build.props`** +- Temporarily suppressed `IDisposableAnalyzers` warnings (IDISP001, IDISP003, IDISP017) +- **Note**: 237+ analyzer violations introduced by transitive dependency will be addressed in **follow-up PR** + +### **4. Telemetry & Observability** + +#### **`FoundryLocalDownloadEvent.cs` (55 new lines)** +- Logs all download attempts with model alias, success/failure status, and error messages +- Uses `LogLevel.Info` for success, `LogLevel.Critical` for failures +- Enables data-driven monitoring of FoundryLocal integration health + +### **5. Data Model Simplification** + +#### **`FoundryCatalogModel.cs` (89 lines reduced)** +- Removed manual JSON serialization attributes +- Removed unused fields (`Tag`, `ProviderType`, `PromptTemplate`, `Uri`) +- Cleaner structure: `Name`, `DisplayName`, `Alias`, `FileSizeMb`, `License`, `ModelId`, `Runtime` + +**Deleted Files:** +- `FoundryServiceManager.cs` (81 lines)—replaced by SDK's `FoundryLocalManager` +- `FoundryJsonContext.cs` (18 lines)—no longer needed with SDK +- `Utils.cs` (40 lines)—functionality absorbed into SDK + +--- + +## **Migration Logic & Business Flow** + +### **Before (HTTP-based)** +``` +User clicks download + ↓ +Custom HTTP POST to /openai/download + ↓ +Manual SSE stream parsing with regex + ↓ +Fragile blob storage path resolution via separate HTTP call + ↓ +Manual JSON deserialization (breaks on format changes) + ↓ +Hope it worked 🤞 +``` + +### **After (SDK-based)** +``` +User clicks download + ↓ +SDK: catalog.GetModelAsync(alias) + ↓ +SDK: model.DownloadAsync(progress callback) + ↓ +SDK: model.LoadAsync() [prepare for use] + ↓ +Store in _preparedModels dictionary + ↓ +Ready for inference ✅ +``` + +### **Inference Flow** +``` +User starts chat + ↓ +GetIChatClient(url) → extract alias + ↓ +Check _preparedModels[alias] (must be prepared beforehand) + ↓ +model.GetChatClientAsync() [SDK native client] + ↓ +Wrap in FoundryLocalChatClientAdapter + ↓ +Stream responses directly via SDK (no web service) +``` + +--- + +## **Known Limitations & Follow-up Work** + +### **IDisposableAnalyzers Build Warnings** +- **Root Cause**: `Microsoft.AI.Foundry.Local.WinML` (v0.8.2.1) includes `IDisposableAnalyzers` (v4.0.8) as a transitive dependency +- **Impact**: 237+ analyzer violations across codebase related to improper `IDisposable` pattern usage +- **Temporary Solution**: Suppressed IDISP001, IDISP003, IDISP017 in `Directory.Build.props` and project files +- **Planned Remediation**: Dedicated follow-up PR to address all violations project-wide + +--- + +## **Testing & Validation Checklist** + +### **Functional Testing** + +#### **Model Catalog & Discovery** +- [ ] Verify model catalog listing works correctly +- [ ] Confirm all models display correct `DisplayName`, `Alias`, and `FileSizeMb` +- [ ] Validate models are grouped by `Alias` properly (multiple variants per alias) +- [ ] Test cached models are correctly identified and marked as downloaded +- [ ] Verify `GetAllModelsInCatalog()` returns full catalog including non-downloaded models + +#### **Model Download Flow** +- [ ] Test model download with progress reporting (0% → 100%) +- [ ] Verify progress callback fires at reasonable intervals during download +- [ ] Test download cancellation via `CancellationToken` works correctly +- [ ] Confirm already-downloaded models skip re-download and return success immediately +- [ ] Verify download failure scenarios log telemetry with error messages +- [ ] Test multiple concurrent downloads are handled gracefully (no race conditions) +- [ ] Verify network interruption during download is handled with proper error messaging + +#### **Model Preparation & Loading** +- [ ] Test `EnsureModelReadyAsync()` successfully prepares models for first use +- [ ] Verify model preparation succeeds without deadlocks in UI thread contexts +- [ ] Confirm already-prepared models skip re-preparation (idempotent behavior) +- [ ] Test concurrent `EnsureModelReadyAsync()` calls for same model are handled safely (semaphore lock) +- [ ] Verify `GetPreparedModel()` returns `null` for unprepared models +- [ ] Verify `GetPreparedModel()` returns valid `IModel` for prepared models +- [ ] Test model loading (`LoadAsync`) completes successfully on both x64 and ARM64 + +#### **Chat Inference & Streaming** +- [ ] Verify `GetIChatClient()` throws appropriate exception when model not prepared +- [ ] Test chat inference streaming works end-to-end with proper response chunks +- [ ] Verify `MaxTokens` parameter properly limits output length +- [ ] Test `ChatOptions` parameters (temperature, top-p, frequency penalty) are correctly applied +- [ ] Verify streaming handles model-generated stop conditions gracefully +- [ ] Test empty or null message inputs are handled with appropriate errors +- [ ] Verify long conversation histories are processed without truncation issues +- [ ] Test multiple concurrent chat sessions on same model work correctly + +#### **Telemetry & Observability** +- [ ] Verify telemetry events fire correctly for download success +- [ ] Verify telemetry events fire correctly for download failures with error details +- [ ] Confirm `FoundryLocalDownloadEvent` includes correct `ModelAlias`, `Success`, and `ErrorMessage` +- [ ] Verify failed downloads log with `LogLevel.Critical` as expected + +#### **Code Generation** +- [ ] Verify `GetIChatClientString()` returns valid, compilable C# code +- [ ] Confirm generated code includes proper SDK initialization pattern +- [ ] Verify generated code uses correct model `Alias` (not obsolete `Name`) +- [ ] Confirm generated code includes necessary `using` statements + +### **Regression Testing** + +#### **Multi-Provider Compatibility** +- [ ] Verify existing non-FoundryLocal model providers (OpenAI, Ollama, etc.) remain unaffected +- [ ] Test switching between FoundryLocal and other providers works seamlessly +- [ ] Verify model picker UI correctly displays all provider types + +#### **UI/UX Flows** +- [ ] Verify FoundryLocal model picker view displays correctly +- [ ] Test download progress UI updates smoothly (no flickering or freezing) +- [ ] Verify model preparation status is shown to user when model not ready +- [ ] Test error messages are displayed to user on download/preparation failures +- [ ] Verify model details (size, license, description) are rendered correctly + +#### **Project Generation** +- [ ] Test project generation with FoundryLocal models produces correct code samples +- [ ] Verify generated projects reference correct NuGet packages (`Microsoft.AI.Foundry.Local.WinML`, `Microsoft.Extensions.AI`) +- [ ] Confirm generated projects compile without errors +- [ ] Verify `NugetPackageReferences` property returns correct package list + +#### **Platform-Specific Validation** +- [ ] **Windows x64**: Verify CUDA libraries are excluded as expected (except TRT dependencies) +- [ ] **Windows ARM64**: Verify QNN libraries are excluded as expected +- [ ] **Windows ARM64**: Test models run on Qualcomm NPU when available +- [ ] Verify no APPX1101 duplicate DLL errors occur on either platform + +### **Edge Cases & Error Handling** + +#### **Service Availability** +- [ ] Verify `IsAvailable()` returns `false` when FoundryLocal not installed/initialized +- [ ] Test graceful degradation when FoundryLocal service fails to start +- [ ] Verify appropriate user messaging when SDK initialization fails + +#### **Invalid Inputs** +- [ ] Test `GetIChatClient()` with invalid URL format throws clear exception +- [ ] Verify `DownloadModel()` with non-FoundryCatalogModel returns `false` +- [ ] Test `EnsureModelReadyAsync()` with non-existent alias throws clear exception + +#### **Resource Management** +- [ ] Verify `FoundryClient.Dispose()` is called properly (no resource leaks) +- [ ] Verify `_prepareLock` semaphore is disposed correctly +- [ ] Confirm models managed by SDK singleton—no manual disposal attempted + +#### **State Consistency** +- [ ] Verify `Reset()` clears `_downloadedModels` cache correctly +- [ ] Test `ignoreCached=true` in `GetModelsAsync()` forces fresh catalog fetch +- [ ] Verify service URL caching works correctly across multiple calls + +### **Performance Testing** + +#### **Initialization** +- [ ] Measure first `InitializeAsync()` completes within acceptable time (< 5 seconds) +- [ ] Measure subsequent calls with cached data complete quickly (< 100ms) + +#### **Model Operations** +- [ ] Test large model downloads (> 5GB) complete with stable memory usage +- [ ] Verify model preparation doesn't block UI thread +- [ ] Measure streaming inference has acceptable latency (tokens/second) + +#### **Concurrency** +- [ ] Test multiple models can be prepared simultaneously without contention +- [ ] Verify concurrent chat sessions scale appropriately with available resources + +--- + +## **Breaking Changes** + +**None for end users**. Internal API changes only: +- Model URL format: `fl://` → `fl://` (transparent to users) +- Internal service architecture: HTTP client → SDK (transparent to users) + +--- + +## **Migration Checklist** + +- [x] Migrate `FoundryClient` to SDK-based implementation +- [x] Update `FoundryLocalModelProvider` for new model lifecycle +- [x] Create `FoundryLocalChatClientAdapter` for `IChatClient` compatibility +- [x] Add telemetry for download success/failure tracking +- [x] Configure build to exclude conflicting ONNX Runtime libraries +- [x] Update NuGet package sources and dependencies +- [x] Clean up obsolete code (service manager, JSON context, utils) +- [x] Suppress IDisposableAnalyzers warnings temporarily +- [ ] **TODO (next PR)**: Fix all IDisposableAnalyzers violations + +--- + +## **Files Changed (18 files)** + +| File | Lines Changed | Type | +|------|---------------|------| +| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs` | +211/-86 | Modified | +| `AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs` | +92/-46 | Modified | +| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs` | +119 | New | +| `AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs` | +55 | New | +| `AIDevGallery/ExcludeExtraLibs.props` | +35 | New | +| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs` | +34/-123 | Modified | +| `Directory.Packages.props` | +4/-2 | Modified | +| `nuget.config` | +10/-4 | Modified | +| `Directory.Build.props` | +3 | Modified | +| `AIDevGallery/AIDevGallery.csproj` | +6 | Modified | +| `AIDevGallery/Controls/FoundryLocalPickerView.xaml` | +4/-4 | Modified | +| `AIDevGallery/Controls/FoundryLocalPickerView.xaml.cs` | +5/-4 | Modified | +| `AIDevGallery/Models/GenerateSampleNavigationParameters.cs` | +5 | Modified | +| `AIDevGallery/Pages/Generate.xaml.cs` | +1/-1 | Modified | +| `AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj` | +1/-1 | Modified | +| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs` | -81 | Deleted | +| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs` | -18 | Deleted | +| `AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs` | -40 | Deleted | + +**Total**: 494 insertions(+), 432 deletions(-) + +--- + +## **Reviewers** + +Please review with focus on: +1. **SDK integration correctness**: Proper lifecycle management (catalog → download → prepare → use) +2. **Thread safety**: `_prepareLock` semaphore usage in `PrepareModelAsync` +3. **Error handling**: Download failures, model-not-ready scenarios +4. **Build configuration**: Library exclusion logic in `ExcludeExtraLibs.props` +5. **Telemetry coverage**: Sufficient observability for production monitoring + +--- + +**Bottom Line:** This PR restores critical FoundryLocal functionality while future-proofing the integration against upstream API changes. Users regain reliable model download and inference capabilities, and the codebase is significantly cleaner and more maintainable. From 56841a307b0bcd79ad1e19c424dd211042d0fa6b Mon Sep 17 00:00:00 2001 From: MillyWei Date: Mon, 15 Dec 2025 20:56:32 +0800 Subject: [PATCH 030/115] fix format --- PR_FoundryLocal_SDK_Migration.md | 347 ------------------------------- 1 file changed, 347 deletions(-) delete mode 100644 PR_FoundryLocal_SDK_Migration.md diff --git a/PR_FoundryLocal_SDK_Migration.md b/PR_FoundryLocal_SDK_Migration.md deleted file mode 100644 index 0a13fd82..00000000 --- a/PR_FoundryLocal_SDK_Migration.md +++ /dev/null @@ -1,347 +0,0 @@ -# Fix production-breaking bug: Migrate FoundryLocal integration to official SDK (v0.8.2.1) - -## **Summary** - -This PR resolves a **critical production-breaking bug** that blocked all FoundryLocal model downloads in AI Dev Gallery after upgrading to **Foundry Local v0.8.x+**. We migrate from fragile custom HTTP API calls to the **official `Microsoft.AI.Foundry.Local.WinML` SDK (v0.8.2.1)**, restoring full functionality and establishing a resilient foundation for future compatibility. - -**Impact:** Users can now reliably download, prepare, and use FoundryLocal models. The system is significantly more robust against upstream API changes. - ---- - -## **Background & Root Cause** - -In **July 2024**, Foundry Local changed the internal format of a critical **`Name`** field in its HTTP API response. While this change was handled internally by Foundry Local, it was **not communicated externally**, causing silent incompatibility with AIDG's direct HTTP-based integration. - -**After upgrading to Foundry Local v0.8.x:** -- All model download requests **failed silently** due to field format mismatch -- Downstream workflows (model preparation → chat/inference) were **completely blocked** -- Users experienced **inability to download or use any FoundryLocal models** - -**Business Impact:** -- Disrupted critical developer workflows -- Increased support burden and user frustration -- Eroded trust in AIDG's stability and reliability - ---- - -## **Solution: SDK Migration** - -To eliminate this entire class of failures and future-proof the integration, we **migrate to the official SDK**: - -### **Key Benefits:** -1. **API Stability**: SDK shields us from low-level HTTP API format changes -2. **Versioned Support**: Official, stable integration points maintained by Foundry Local team -3. **Simplified Architecture**: Cleaner code with built-in concurrency and error handling -4. **Direct Model Access**: No web service dependency—models run directly via SDK - ---- - -## **Technical Changes** - -### **1. Core Architecture Refactor** - -#### **`FoundryClient.cs` (297 lines changed)** -- **Before**: Custom HTTP client with manual JSON parsing, regex-based progress tracking, and fragile blob storage path resolution -- **After**: Official SDK usage via `FoundryLocalManager`, `ICatalog`, and `IModel` interfaces -- **Key Improvements**: - - Thread-safe model preparation with semaphore-based locking - - Eliminated ~150 lines of error-prone HTTP/JSON handling code - - Proper async/await patterns throughout - - Implements `IDisposable` for proper resource cleanup - -#### **`FoundryLocalModelProvider.cs` (138 lines changed)** -- **Before**: Direct dependency on `OpenAI` SDK with custom service URL construction -- **After**: SDK-based model lifecycle management (catalog → download → prepare → use) -- **Key Changes**: - - Introduced `EnsureModelReadyAsync()` to prevent deadlocks in synchronous contexts - - Changed model identification from unstable `Name` field to stable `Alias` field - - Added proper model state management (`_preparedModels` dictionary) - - Integrated telemetry for download success/failure tracking - -#### **`FoundryLocalChatClientAdapter.cs` (119 new lines)** -- **Purpose**: Bridge between FoundryLocal SDK's native chat client and `Microsoft.Extensions.AI.IChatClient` interface -- **Key Features**: - - Direct SDK model access (no web service needed) - - Proper streaming response handling - - Complete `ChatOptions` parameter mapping (temperature, top-p, frequency penalty, etc.) - - **Critical Fix**: Sets `MaxTokens` default (1024) to prevent empty model outputs - -### **2. Dependency Updates** - -#### **`Directory.Packages.props`** -- ✅ Added: `Microsoft.AI.Foundry.Local.WinML` (v0.8.2.1) -- ⬆️ Upgraded: `Microsoft.ML.OnnxRuntimeGenAI.Managed` & `.WinML` (v0.10.1 → v0.11.4) - -#### **`nuget.config`** -- Added ORT package source: `https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/ORT/nuget/v3/index.json` -- Configured package source mapping to route `*Foundry*` packages to ORT feed - -### **3. Build Configuration** - -#### **`ExcludeExtraLibs.props` (35 new lines)** -- Resolves APPX1101 duplicate DLL errors by excluding conflicting ONNX Runtime libraries -- Removes CUDA provider libraries on x64 (except TRT dependencies) -- Removes QNN provider libraries on ARM64 -- Adopted from official Foundry Local SDK build patterns - -#### **`Directory.Build.props`** -- Temporarily suppressed `IDisposableAnalyzers` warnings (IDISP001, IDISP003, IDISP017) -- **Note**: 237+ analyzer violations introduced by transitive dependency will be addressed in **follow-up PR** - -### **4. Telemetry & Observability** - -#### **`FoundryLocalDownloadEvent.cs` (55 new lines)** -- Logs all download attempts with model alias, success/failure status, and error messages -- Uses `LogLevel.Info` for success, `LogLevel.Critical` for failures -- Enables data-driven monitoring of FoundryLocal integration health - -### **5. Data Model Simplification** - -#### **`FoundryCatalogModel.cs` (89 lines reduced)** -- Removed manual JSON serialization attributes -- Removed unused fields (`Tag`, `ProviderType`, `PromptTemplate`, `Uri`) -- Cleaner structure: `Name`, `DisplayName`, `Alias`, `FileSizeMb`, `License`, `ModelId`, `Runtime` - -**Deleted Files:** -- `FoundryServiceManager.cs` (81 lines)—replaced by SDK's `FoundryLocalManager` -- `FoundryJsonContext.cs` (18 lines)—no longer needed with SDK -- `Utils.cs` (40 lines)—functionality absorbed into SDK - ---- - -## **Migration Logic & Business Flow** - -### **Before (HTTP-based)** -``` -User clicks download - ↓ -Custom HTTP POST to /openai/download - ↓ -Manual SSE stream parsing with regex - ↓ -Fragile blob storage path resolution via separate HTTP call - ↓ -Manual JSON deserialization (breaks on format changes) - ↓ -Hope it worked 🤞 -``` - -### **After (SDK-based)** -``` -User clicks download - ↓ -SDK: catalog.GetModelAsync(alias) - ↓ -SDK: model.DownloadAsync(progress callback) - ↓ -SDK: model.LoadAsync() [prepare for use] - ↓ -Store in _preparedModels dictionary - ↓ -Ready for inference ✅ -``` - -### **Inference Flow** -``` -User starts chat - ↓ -GetIChatClient(url) → extract alias - ↓ -Check _preparedModels[alias] (must be prepared beforehand) - ↓ -model.GetChatClientAsync() [SDK native client] - ↓ -Wrap in FoundryLocalChatClientAdapter - ↓ -Stream responses directly via SDK (no web service) -``` - ---- - -## **Known Limitations & Follow-up Work** - -### **IDisposableAnalyzers Build Warnings** -- **Root Cause**: `Microsoft.AI.Foundry.Local.WinML` (v0.8.2.1) includes `IDisposableAnalyzers` (v4.0.8) as a transitive dependency -- **Impact**: 237+ analyzer violations across codebase related to improper `IDisposable` pattern usage -- **Temporary Solution**: Suppressed IDISP001, IDISP003, IDISP017 in `Directory.Build.props` and project files -- **Planned Remediation**: Dedicated follow-up PR to address all violations project-wide - ---- - -## **Testing & Validation Checklist** - -### **Functional Testing** - -#### **Model Catalog & Discovery** -- [ ] Verify model catalog listing works correctly -- [ ] Confirm all models display correct `DisplayName`, `Alias`, and `FileSizeMb` -- [ ] Validate models are grouped by `Alias` properly (multiple variants per alias) -- [ ] Test cached models are correctly identified and marked as downloaded -- [ ] Verify `GetAllModelsInCatalog()` returns full catalog including non-downloaded models - -#### **Model Download Flow** -- [ ] Test model download with progress reporting (0% → 100%) -- [ ] Verify progress callback fires at reasonable intervals during download -- [ ] Test download cancellation via `CancellationToken` works correctly -- [ ] Confirm already-downloaded models skip re-download and return success immediately -- [ ] Verify download failure scenarios log telemetry with error messages -- [ ] Test multiple concurrent downloads are handled gracefully (no race conditions) -- [ ] Verify network interruption during download is handled with proper error messaging - -#### **Model Preparation & Loading** -- [ ] Test `EnsureModelReadyAsync()` successfully prepares models for first use -- [ ] Verify model preparation succeeds without deadlocks in UI thread contexts -- [ ] Confirm already-prepared models skip re-preparation (idempotent behavior) -- [ ] Test concurrent `EnsureModelReadyAsync()` calls for same model are handled safely (semaphore lock) -- [ ] Verify `GetPreparedModel()` returns `null` for unprepared models -- [ ] Verify `GetPreparedModel()` returns valid `IModel` for prepared models -- [ ] Test model loading (`LoadAsync`) completes successfully on both x64 and ARM64 - -#### **Chat Inference & Streaming** -- [ ] Verify `GetIChatClient()` throws appropriate exception when model not prepared -- [ ] Test chat inference streaming works end-to-end with proper response chunks -- [ ] Verify `MaxTokens` parameter properly limits output length -- [ ] Test `ChatOptions` parameters (temperature, top-p, frequency penalty) are correctly applied -- [ ] Verify streaming handles model-generated stop conditions gracefully -- [ ] Test empty or null message inputs are handled with appropriate errors -- [ ] Verify long conversation histories are processed without truncation issues -- [ ] Test multiple concurrent chat sessions on same model work correctly - -#### **Telemetry & Observability** -- [ ] Verify telemetry events fire correctly for download success -- [ ] Verify telemetry events fire correctly for download failures with error details -- [ ] Confirm `FoundryLocalDownloadEvent` includes correct `ModelAlias`, `Success`, and `ErrorMessage` -- [ ] Verify failed downloads log with `LogLevel.Critical` as expected - -#### **Code Generation** -- [ ] Verify `GetIChatClientString()` returns valid, compilable C# code -- [ ] Confirm generated code includes proper SDK initialization pattern -- [ ] Verify generated code uses correct model `Alias` (not obsolete `Name`) -- [ ] Confirm generated code includes necessary `using` statements - -### **Regression Testing** - -#### **Multi-Provider Compatibility** -- [ ] Verify existing non-FoundryLocal model providers (OpenAI, Ollama, etc.) remain unaffected -- [ ] Test switching between FoundryLocal and other providers works seamlessly -- [ ] Verify model picker UI correctly displays all provider types - -#### **UI/UX Flows** -- [ ] Verify FoundryLocal model picker view displays correctly -- [ ] Test download progress UI updates smoothly (no flickering or freezing) -- [ ] Verify model preparation status is shown to user when model not ready -- [ ] Test error messages are displayed to user on download/preparation failures -- [ ] Verify model details (size, license, description) are rendered correctly - -#### **Project Generation** -- [ ] Test project generation with FoundryLocal models produces correct code samples -- [ ] Verify generated projects reference correct NuGet packages (`Microsoft.AI.Foundry.Local.WinML`, `Microsoft.Extensions.AI`) -- [ ] Confirm generated projects compile without errors -- [ ] Verify `NugetPackageReferences` property returns correct package list - -#### **Platform-Specific Validation** -- [ ] **Windows x64**: Verify CUDA libraries are excluded as expected (except TRT dependencies) -- [ ] **Windows ARM64**: Verify QNN libraries are excluded as expected -- [ ] **Windows ARM64**: Test models run on Qualcomm NPU when available -- [ ] Verify no APPX1101 duplicate DLL errors occur on either platform - -### **Edge Cases & Error Handling** - -#### **Service Availability** -- [ ] Verify `IsAvailable()` returns `false` when FoundryLocal not installed/initialized -- [ ] Test graceful degradation when FoundryLocal service fails to start -- [ ] Verify appropriate user messaging when SDK initialization fails - -#### **Invalid Inputs** -- [ ] Test `GetIChatClient()` with invalid URL format throws clear exception -- [ ] Verify `DownloadModel()` with non-FoundryCatalogModel returns `false` -- [ ] Test `EnsureModelReadyAsync()` with non-existent alias throws clear exception - -#### **Resource Management** -- [ ] Verify `FoundryClient.Dispose()` is called properly (no resource leaks) -- [ ] Verify `_prepareLock` semaphore is disposed correctly -- [ ] Confirm models managed by SDK singleton—no manual disposal attempted - -#### **State Consistency** -- [ ] Verify `Reset()` clears `_downloadedModels` cache correctly -- [ ] Test `ignoreCached=true` in `GetModelsAsync()` forces fresh catalog fetch -- [ ] Verify service URL caching works correctly across multiple calls - -### **Performance Testing** - -#### **Initialization** -- [ ] Measure first `InitializeAsync()` completes within acceptable time (< 5 seconds) -- [ ] Measure subsequent calls with cached data complete quickly (< 100ms) - -#### **Model Operations** -- [ ] Test large model downloads (> 5GB) complete with stable memory usage -- [ ] Verify model preparation doesn't block UI thread -- [ ] Measure streaming inference has acceptable latency (tokens/second) - -#### **Concurrency** -- [ ] Test multiple models can be prepared simultaneously without contention -- [ ] Verify concurrent chat sessions scale appropriately with available resources - ---- - -## **Breaking Changes** - -**None for end users**. Internal API changes only: -- Model URL format: `fl://` → `fl://` (transparent to users) -- Internal service architecture: HTTP client → SDK (transparent to users) - ---- - -## **Migration Checklist** - -- [x] Migrate `FoundryClient` to SDK-based implementation -- [x] Update `FoundryLocalModelProvider` for new model lifecycle -- [x] Create `FoundryLocalChatClientAdapter` for `IChatClient` compatibility -- [x] Add telemetry for download success/failure tracking -- [x] Configure build to exclude conflicting ONNX Runtime libraries -- [x] Update NuGet package sources and dependencies -- [x] Clean up obsolete code (service manager, JSON context, utils) -- [x] Suppress IDisposableAnalyzers warnings temporarily -- [ ] **TODO (next PR)**: Fix all IDisposableAnalyzers violations - ---- - -## **Files Changed (18 files)** - -| File | Lines Changed | Type | -|------|---------------|------| -| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs` | +211/-86 | Modified | -| `AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs` | +92/-46 | Modified | -| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs` | +119 | New | -| `AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs` | +55 | New | -| `AIDevGallery/ExcludeExtraLibs.props` | +35 | New | -| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryCatalogModel.cs` | +34/-123 | Modified | -| `Directory.Packages.props` | +4/-2 | Modified | -| `nuget.config` | +10/-4 | Modified | -| `Directory.Build.props` | +3 | Modified | -| `AIDevGallery/AIDevGallery.csproj` | +6 | Modified | -| `AIDevGallery/Controls/FoundryLocalPickerView.xaml` | +4/-4 | Modified | -| `AIDevGallery/Controls/FoundryLocalPickerView.xaml.cs` | +5/-4 | Modified | -| `AIDevGallery/Models/GenerateSampleNavigationParameters.cs` | +5 | Modified | -| `AIDevGallery/Pages/Generate.xaml.cs` | +1/-1 | Modified | -| `AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj` | +1/-1 | Modified | -| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryServiceManager.cs` | -81 | Deleted | -| `AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryJsonContext.cs` | -18 | Deleted | -| `AIDevGallery/ExternalModelUtils/FoundryLocal/Utils.cs` | -40 | Deleted | - -**Total**: 494 insertions(+), 432 deletions(-) - ---- - -## **Reviewers** - -Please review with focus on: -1. **SDK integration correctness**: Proper lifecycle management (catalog → download → prepare → use) -2. **Thread safety**: `_prepareLock` semaphore usage in `PrepareModelAsync` -3. **Error handling**: Download failures, model-not-ready scenarios -4. **Build configuration**: Library exclusion logic in `ExcludeExtraLibs.props` -5. **Telemetry coverage**: Sufficient observability for production monitoring - ---- - -**Bottom Line:** This PR restores critical FoundryLocal functionality while future-proofing the integration against upstream API changes. Users regain reliable model download and inference capabilities, and the codebase is significantly cleaner and more maintainable. From 0714600626bba1c3b9e3e9733ae31fa763aeed6a Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 17:33:25 +0800 Subject: [PATCH 031/115] Add test infra --- .github/workflows/build.yml | 68 ++- .../AIDevGallery.Tests.csproj | 10 +- .../IntegrationTests/ProjectGeneratorTests.cs | 47 +- .../Package.appxmanifest | 6 +- .../PublishProfiles/win-arm64.pubxml | 0 .../Properties/PublishProfiles/win-x64.pubxml | 0 .../Properties/launchSettings.json | 4 +- AIDevGallery.Tests/TestInfra/FlaUITestBase.cs | 478 ++++++++++++++++ .../TestInfra/NativeUIA3TestBase.cs | 527 ++++++++++++++++++ .../TestInfra/PerformanceCollector.cs | 283 ++++++++++ .../TestInfra}/UnitTestApp.xaml | 4 +- .../TestInfra}/UnitTestApp.xaml.cs | 2 +- .../TestInfra}/UnitTestAppWindow.xaml | 4 +- .../TestInfra}/UnitTestAppWindow.xaml.cs | 2 +- .../UITests/BasicInteractionTests.cs | 171 ++++++ AIDevGallery.Tests/UITests/MainWindowTests.cs | 301 ++++++++++ .../UITests/NativeUIA3SmokeTests.cs | 406 ++++++++++++++ .../UITests/NavigationViewTests.cs | 74 +++ AIDevGallery.Tests/UITests/SmokeTests.cs | 214 +++++++ .../UnitTests/Utils/AppUtilsTests.cs | 156 ++++++ .../app.manifest | 2 +- AIDevGallery.sln | 2 +- AIDevGallery/AIDevGallery.csproj | 2 +- Directory.Packages.props | 2 + 24 files changed, 2722 insertions(+), 43 deletions(-) rename AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj => AIDevGallery.Tests/AIDevGallery.Tests.csproj (86%) rename AIDevGallery.UnitTests/ProjectGeneratorUnitTests.cs => AIDevGallery.Tests/IntegrationTests/ProjectGeneratorTests.cs (90%) rename {AIDevGallery.UnitTests => AIDevGallery.Tests}/Package.appxmanifest (92%) rename {AIDevGallery.UnitTests => AIDevGallery.Tests}/Properties/PublishProfiles/win-arm64.pubxml (100%) rename {AIDevGallery.UnitTests => AIDevGallery.Tests}/Properties/PublishProfiles/win-x64.pubxml (100%) rename {AIDevGallery.UnitTests => AIDevGallery.Tests}/Properties/launchSettings.json (53%) create mode 100644 AIDevGallery.Tests/TestInfra/FlaUITestBase.cs create mode 100644 AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs create mode 100644 AIDevGallery.Tests/TestInfra/PerformanceCollector.cs rename {AIDevGallery.UnitTests => AIDevGallery.Tests/TestInfra}/UnitTestApp.xaml (92%) rename {AIDevGallery.UnitTests => AIDevGallery.Tests/TestInfra}/UnitTestApp.xaml.cs (97%) rename {AIDevGallery.UnitTests => AIDevGallery.Tests/TestInfra}/UnitTestAppWindow.xaml (79%) rename {AIDevGallery.UnitTests => AIDevGallery.Tests/TestInfra}/UnitTestAppWindow.xaml.cs (92%) create mode 100644 AIDevGallery.Tests/UITests/BasicInteractionTests.cs create mode 100644 AIDevGallery.Tests/UITests/MainWindowTests.cs create mode 100644 AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs create mode 100644 AIDevGallery.Tests/UITests/NavigationViewTests.cs create mode 100644 AIDevGallery.Tests/UITests/SmokeTests.cs create mode 100644 AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs rename {AIDevGallery.UnitTests => AIDevGallery.Tests}/app.manifest (92%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 34fd6bd4..8b23dad9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,59 @@ on: branches: [ "main", "dev/**" ] jobs: + pr-unit-test: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main') + strategy: + fail-fast: false + matrix: + include: + - os: windows-2025 + dotnet-arch: 'x64' + dotnet-configuration: 'Release' + name: PR Unit Tests - win-${{ matrix.dotnet-arch }} + runs-on: ${{ matrix.os }} + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v2 + - name: Restore dependencies + run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} + - name: Build Test Project + run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} + - name: Run Unit Tests + run: | + $testAssembly = ".\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.exe" + $testAdapterPath = "$HOME\.nuget\packages\mstest.testadapter\3.9.2\buildTransitive\net9.0" + + # Create TestResults directory if it doesn't exist + New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null + + # Run only unit tests (exclude IntegrationTests and UITests) + vstest.console.exe $testAssembly /TestCaseFilter:"FullyQualifiedName~UnitTests" /TestAdapterPath:$testAdapterPath /logger:"trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" + + if ($LASTEXITCODE -ne 0) { + Write-Error "Unit tests failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + - name: Publish Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-unit-test-results-${{ matrix.dotnet-arch }} + path: TestResults + build: + needs: pr-unit-test + if: ${{ !cancelled() && (needs.pr-unit-test.result == 'success' || needs.pr-unit-test.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -67,6 +119,8 @@ jobs: name: MSIX-${{ matrix.dotnet-arch }} path: ${{ github.workspace }}/AIDevGallery/AppPackages/*_${{ matrix.dotnet-arch }}_Test/AIDevGallery_*_${{ matrix.dotnet-arch }}.msix test: + needs: pr-unit-test + if: ${{ github.event_name == 'workflow_dispatch' && !cancelled() && (needs.pr-unit-test.result == 'success' || needs.pr-unit-test.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -118,10 +172,12 @@ jobs: dotnet build AIDevGallery --no-restore -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" - name: Setup Dev Tools uses: ilammy/msvc-dev-cmd@v1 - - name: Build Tests - run: dotnet build AIDevGallery.UnitTests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - - name: Run Tests - run: vstest.console.exe .\AIDevGallery.UnitTests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.UnitTests.exe /TestAdapterPath:"$HOME\.nuget\mstest.testadapter\3.9.2\buildTransitive\net9.0" /logger:"trx;LogFileName=${{ github.workspace }}\TestResults\VsTestResults.trx" + - name: Build Full Test Suite + if: github.event_name == 'workflow_dispatch' + run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} + - name: Run All Tests + if: github.event_name == 'workflow_dispatch' + run: vstest.console.exe .\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.exe /TestAdapterPath:"$HOME\.nuget\mstest.testadapter\3.9.2\buildTransitive\net9.0" /logger:"trx;LogFileName=${{ github.workspace }}\TestResults\VsTestResults.trx" - name: Publish Test Builds If Failed if: failure() uses: actions/upload-artifact@v4 @@ -130,8 +186,8 @@ jobs: path: | .\AIDevGallery\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} .\AIDevGallery\obj\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} - .\AIDevGallery.UnitTests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} - .\AIDevGallery.UnitTests\obj\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} + .\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} + .\AIDevGallery.Tests\obj\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} - name: Publish Test Results if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 diff --git a/AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj b/AIDevGallery.Tests/AIDevGallery.Tests.csproj similarity index 86% rename from AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj rename to AIDevGallery.Tests/AIDevGallery.Tests.csproj index 4797744b..984a9bdb 100644 --- a/AIDevGallery.UnitTests/AIDevGallery.UnitTests.csproj +++ b/AIDevGallery.Tests/AIDevGallery.Tests.csproj @@ -3,7 +3,7 @@ WinExe net9.0-windows10.0.26100.0 10.0.17763.0 - AIDevGallery.UnitTests + AIDevGallery.Tests app.manifest x64;ARM64 win-x64;win-arm64 @@ -24,8 +24,8 @@ - - + + @@ -37,6 +37,10 @@ build + + + + diff --git a/AIDevGallery.UnitTests/ProjectGeneratorUnitTests.cs b/AIDevGallery.Tests/IntegrationTests/ProjectGeneratorTests.cs similarity index 90% rename from AIDevGallery.UnitTests/ProjectGeneratorUnitTests.cs rename to AIDevGallery.Tests/IntegrationTests/ProjectGeneratorTests.cs index 9c627c6f..f8a9b409 100644 --- a/AIDevGallery.UnitTests/ProjectGeneratorUnitTests.cs +++ b/AIDevGallery.Tests/IntegrationTests/ProjectGeneratorTests.cs @@ -22,7 +22,7 @@ using System.Threading; using System.Threading.Tasks; -namespace AIDevGallery.UnitTests; +namespace AIDevGallery.Tests.Integration; #pragma warning disable MVVMTK0045 // Using [ObservableProperty] on fields is not AOT compatible for WinRT #pragma warning disable SA1307 // Accessible fields should begin with upper-case letter @@ -139,28 +139,35 @@ public static async Task Initialize(TestContext context) Directory.CreateDirectory(TmpPathProjectGenerator); Directory.CreateDirectory(TmpPathLogs); - TaskCompletionSource taskCompletionSource = new(); - - UITestMethodAttribute.DispatcherQueue?.TryEnqueue(() => + if (UITestMethodAttribute.DispatcherQueue != null) { - SampleUIData.greenSolidColorBrush = new(Colors.Green); - SampleUIData.redSolidColorBrush = new(Colors.Red); - SampleUIData.yellowSolidColorBrush = new(Colors.Yellow); - SampleUIData.graySolidColorBrush = new(Colors.LightGray); - - Source = SampleDetails.Samples.SelectMany(s => GetAllForSample(s)).ToList(); + TaskCompletionSource taskCompletionSource = new(); - listView = new ListView + UITestMethodAttribute.DispatcherQueue.TryEnqueue(() => { - ItemsSource = Source, - ItemTemplate = Microsoft.UI.Xaml.Application.Current.Resources["SampleItemTemplate"] as Microsoft.UI.Xaml.DataTemplate - }; - UnitTestApp.SetWindowContent(listView); + SampleUIData.greenSolidColorBrush = new(Colors.Green); + SampleUIData.redSolidColorBrush = new(Colors.Red); + SampleUIData.yellowSolidColorBrush = new(Colors.Yellow); + SampleUIData.graySolidColorBrush = new(Colors.LightGray); - taskCompletionSource.SetResult(); - }); + Source = SampleDetails.Samples.SelectMany(s => GetAllForSample(s)).ToList(); + + listView = new ListView + { + ItemsSource = Source, + ItemTemplate = Microsoft.UI.Xaml.Application.Current.Resources["SampleItemTemplate"] as Microsoft.UI.Xaml.DataTemplate + }; + UnitTestApp.SetWindowContent(listView); - await taskCompletionSource.Task; + taskCompletionSource.SetResult(); + }); + + await taskCompletionSource.Task; + } + else + { + Source = SampleDetails.Samples.SelectMany(s => GetAllForSample(s)).ToList(); + } context.WriteLine($"Running {Source.Count} tests"); } @@ -217,7 +224,7 @@ await Parallel.ForEachAsync( public async Task GenerateForSampleUI(SampleUIData item, CancellationToken ct) { - listView.DispatcherQueue.TryEnqueue(() => + listView?.DispatcherQueue?.TryEnqueue(() => { item.StatusColor = SampleUIData.yellowSolidColorBrush; }); @@ -227,7 +234,7 @@ public async Task GenerateForSampleUI(SampleUIData item, CancellationToken ct) TestContext.WriteLine($"Built {item.SampleName} with status {success}"); Debug.WriteLine($"Built {item.SampleName} with status {success}"); - listView.DispatcherQueue.TryEnqueue(() => + listView?.DispatcherQueue?.TryEnqueue(() => { item.StatusColor = success ? SampleUIData.greenSolidColorBrush : SampleUIData.redSolidColorBrush; }); diff --git a/AIDevGallery.UnitTests/Package.appxmanifest b/AIDevGallery.Tests/Package.appxmanifest similarity index 92% rename from AIDevGallery.UnitTests/Package.appxmanifest rename to AIDevGallery.Tests/Package.appxmanifest index cc5546a1..f9a9e289 100644 --- a/AIDevGallery.UnitTests/Package.appxmanifest +++ b/AIDevGallery.Tests/Package.appxmanifest @@ -15,7 +15,7 @@ - AIDevGallery.UnitTests + AIDevGallery.Tests alzollin Assets\AppIcon\StoreLogo.png @@ -34,8 +34,8 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> diff --git a/AIDevGallery.UnitTests/Properties/PublishProfiles/win-arm64.pubxml b/AIDevGallery.Tests/Properties/PublishProfiles/win-arm64.pubxml similarity index 100% rename from AIDevGallery.UnitTests/Properties/PublishProfiles/win-arm64.pubxml rename to AIDevGallery.Tests/Properties/PublishProfiles/win-arm64.pubxml diff --git a/AIDevGallery.UnitTests/Properties/PublishProfiles/win-x64.pubxml b/AIDevGallery.Tests/Properties/PublishProfiles/win-x64.pubxml similarity index 100% rename from AIDevGallery.UnitTests/Properties/PublishProfiles/win-x64.pubxml rename to AIDevGallery.Tests/Properties/PublishProfiles/win-x64.pubxml diff --git a/AIDevGallery.UnitTests/Properties/launchSettings.json b/AIDevGallery.Tests/Properties/launchSettings.json similarity index 53% rename from AIDevGallery.UnitTests/Properties/launchSettings.json rename to AIDevGallery.Tests/Properties/launchSettings.json index fb1e9a6b..2dfdcb66 100644 --- a/AIDevGallery.UnitTests/Properties/launchSettings.json +++ b/AIDevGallery.Tests/Properties/launchSettings.json @@ -1,9 +1,9 @@ { "profiles": { - "AIDevGallery.UnitTests (Package)": { + "AIDevGallery.Tests (Package)": { "commandName": "MsixPackage" }, - "AIDevGallery.UnitTests (Unpackaged)": { + "AIDevGallery.Tests (Unpackaged)": { "commandName": "Project" } } diff --git a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs new file mode 100644 index 00000000..30f06420 --- /dev/null +++ b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs @@ -0,0 +1,478 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.UIA3; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; + +namespace AIDevGallery.Tests.TestInfra; + +/// +/// Base class for FlaUI-based UI tests. +/// Provides common functionality for launching and managing the AIDevGallery application. +/// +public abstract class FlaUITestBase +{ + protected Application? App { get; private set; } + protected UIA3Automation? Automation { get; private set; } + protected Window? MainWindow { get; private set; } + protected DateTime AppLaunchStartTime { get; private set; } + + /// + /// Gets the path to the AIDevGallery executable. + /// + /// + protected virtual string GetApplicationPath() + { + // Try to find the built application + var solutionDir = FindSolutionDirectory(); + if (solutionDir == null) + { + throw new FileNotFoundException("Could not find solution directory"); + } + + // Determine architecture + var arch = Environment.Is64BitOperatingSystem ? "x64" : "x86"; + + // Try multiple possible locations + var possiblePaths = new[] + { + // Debug builds + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Debug", "net9.0-windows10.0.26100.0", $"win-{arch}", "AIDevGallery.exe"), + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Debug", "net9.0-windows10.0.26100.0", "AIDevGallery.exe"), + + // Release builds + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Release", "net9.0-windows10.0.26100.0", $"win-{arch}", "AIDevGallery.exe"), + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Release", "net9.0-windows10.0.26100.0", "AIDevGallery.exe"), + + // AppPackages (for packaged builds) + Path.Combine(solutionDir, "AIDevGallery", "AppPackages", "AIDevGallery.exe"), + }; + + foreach (var path in possiblePaths) + { + if (File.Exists(path)) + { + Console.WriteLine($"Found application at: {path}"); + return path; + } + } + + var searchedPaths = string.Join(Environment.NewLine + " ", possiblePaths); + throw new FileNotFoundException( + $"Could not find AIDevGallery.exe at any expected location. Please build the application first.{Environment.NewLine}" + + $"Searched paths:{Environment.NewLine} {searchedPaths}"); + } + + /// + /// Finds the solution directory by walking up from the current directory. + /// + private static string? FindSolutionDirectory() + { + var currentDir = Directory.GetCurrentDirectory(); + while (currentDir != null) + { + if (File.Exists(Path.Combine(currentDir, "AIDevGallery.sln"))) + { + return currentDir; + } + + var parent = Directory.GetParent(currentDir); + currentDir = parent?.FullName; + } + + return null; + } + + /// + /// Try to get the package family name if the app is installed via MSIX. + /// + private static string? TryGetInstalledPackageFamilyName() + { + try + { + // Use PowerShell to query installed packages + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -Command \"Get-AppxPackage | Where-Object {$_.Name -like '*e7af07c0-77d2-43e5-ab82-9cdb9daa11b3*'} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process == null) + { + return null; + } + + var output = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) + { + return output; + } + + if (!string.IsNullOrEmpty(error)) + { + Console.WriteLine($"Error querying packages: {error}"); + } + + return null; + } + catch (Exception ex) + { + Console.WriteLine($"Exception while checking for MSIX package: {ex.Message}"); + return null; + } + } + + /// + /// Find the AIDevGallery application process. + /// + private static Process? FindApplicationProcess() + { + // Try multiple times with delays + for (int attempt = 0; attempt < 10; attempt++) + { + var processes = Process.GetProcessesByName("AIDevGallery"); + if (processes.Length > 0) + { + // Return the most recently started process + return processes.OrderByDescending(p => p.StartTime).First(); + } + + Thread.Sleep(500); + } + + return null; + } + + /// + /// Initializes the test by launching the application. + /// + [TestInitialize] + public virtual void TestInitialize() + { + Automation = new UIA3Automation(); + + // Check if application is already running and close it + CloseExistingApplicationInstances(); + + // Record start time + AppLaunchStartTime = DateTime.UtcNow; + + // Launch the application + Console.WriteLine("Attempting to launch AIDevGallery..."); + + // First try to find installed MSIX package + var packageFamilyName = TryGetInstalledPackageFamilyName(); + + if (!string.IsNullOrEmpty(packageFamilyName)) + { + Console.WriteLine($"Found installed MSIX package: {packageFamilyName}"); + Console.WriteLine("Launching via PowerShell..."); + + try + { + // Launch MSIX app using PowerShell Get-AppxPackage and Start-Process + var appUserModelId = $"{packageFamilyName}!App"; + + var psScript = $"Start-Process 'shell:AppsFolder\\{appUserModelId}'"; + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -WindowStyle Hidden -Command \"{psScript}\"", + UseShellExecute = false, + CreateNoWindow = true + }; + + // Launch via PowerShell + using (var psProcess = Process.Start(startInfo)) + { + psProcess?.WaitForExit(5000); + } + + // Wait for the app process to start + Console.WriteLine("Waiting for application process to start..."); + Thread.Sleep(2000); + + // Find the app process + var appProcess = FindApplicationProcess(); + if (appProcess == null) + { + throw new InvalidOperationException("Application process not found after launch"); + } + + Console.WriteLine($"Found application process with PID: {appProcess.Id}"); + App = Application.Attach(appProcess.Id); + Console.WriteLine($"Attached to application with PID: {App.ProcessId}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to launch via MSIX: {ex.Message}"); + throw new InvalidOperationException( + $"Could not launch MSIX package: {packageFamilyName}{Environment.NewLine}" + + $"Error: {ex.Message}{Environment.NewLine}" + + $"Try launching the app manually from Start Menu to verify it works.", ex); + } + } + else + { + // Fall back to unpackaged exe (will likely fail with COM error) + var appPath = GetApplicationPath(); + Console.WriteLine($"WARNING: No MSIX package found. Trying unpackaged exe: {appPath}"); + Console.WriteLine("This will likely fail with COM registration errors!"); + Console.WriteLine($"Please deploy the MSIX package first. See: MSIX_DEPLOYMENT_REQUIRED.md"); + + try + { + App = Application.Launch(appPath); + Console.WriteLine($"Application launched with PID: {App.ProcessId}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to launch unpackaged application: {ex.Message}"); + throw new InvalidOperationException( + $"Could not launch unpackaged application. WinUI3 requires MSIX deployment for testing.{Environment.NewLine}" + + $"Please run: msbuild AIDevGallery\\AIDevGallery.csproj /t:Deploy /p:Configuration=Debug /p:Platform=x64{Environment.NewLine}" + + $"See MSIX_DEPLOYMENT_REQUIRED.md for details.", ex); + } + } + + // Wait for the main window to appear with extended timeout + var timeout = TimeSpan.FromSeconds(60); // Increased timeout for first launch + var startTime = DateTime.Now; + var retryInterval = TimeSpan.FromMilliseconds(500); + + Console.WriteLine("Waiting for main window to appear..."); + + while (MainWindow == null && DateTime.Now - startTime < timeout) + { + try + { + // Try to get main window with a short timeout for each attempt + MainWindow = App.GetMainWindow(Automation, TimeSpan.FromSeconds(2)); + + if (MainWindow != null && MainWindow.IsAvailable) + { + Console.WriteLine($"Main window found after {(DateTime.Now - startTime).TotalSeconds:F1} seconds"); + break; + } + } + catch (Exception ex) + { + // Window not ready yet, continue waiting + var elapsed = DateTime.Now - startTime; + if (elapsed.TotalSeconds % 10 < 1) // Log every ~10 seconds + { + Console.WriteLine($"Still waiting for window... ({elapsed.TotalSeconds:F0}s elapsed)"); + } + } + + Thread.Sleep(retryInterval); + } + + if (MainWindow == null) + { + var elapsed = DateTime.Now - startTime; + Console.WriteLine($"Failed to find main window after {elapsed.TotalSeconds:F1} seconds"); + + // Try to get diagnostic information + try + { + var allWindows = App.GetAllTopLevelWindows(Automation); + Console.WriteLine($"Found {allWindows.Length} top-level windows"); + foreach (var window in allWindows.Take(5)) + { + Console.WriteLine($" Window: Title='{window.Title}', ClassName='{window.ClassName}'"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Could not get diagnostic info: {ex.Message}"); + } + + throw new InvalidOperationException($"Failed to get main window within {timeout.TotalSeconds} seconds"); + } + + // Give the UI a moment to fully initialize + Console.WriteLine("Main window ready, waiting for UI initialization..."); + Thread.Sleep(2000); + } + + /// + /// Cleans up after the test by closing the application. + /// + [TestCleanup] + public virtual void TestCleanup() + { + Console.WriteLine("Cleaning up test..."); + + try + { + if (MainWindow != null && MainWindow.IsAvailable) + { + Console.WriteLine("Closing main window..."); + MainWindow.Close(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error closing main window: {ex.Message}"); + } + + try + { + if (App != null) + { + Console.WriteLine("Closing application..."); + App.Close(); + App.Dispose(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error closing application: {ex.Message}"); + } + finally + { + try + { + Automation?.Dispose(); + } + catch (Exception ex) + { + Console.WriteLine($"Error disposing automation: {ex.Message}"); + } + + CloseExistingApplicationInstances(); + Console.WriteLine("Test cleanup completed"); + } + } + + /// + /// Closes any existing instances of AIDevGallery. + /// + private static void CloseExistingApplicationInstances() + { + var processes = Process.GetProcessesByName("AIDevGallery"); + if (processes.Length > 0) + { + Console.WriteLine($"Found {processes.Length} existing AIDevGallery process(es), terminating..."); + } + + foreach (var process in processes) + { + try + { + var processId = process.Id; + process.Kill(entireProcessTree: true); + var exited = process.WaitForExit(5000); + + if (exited) + { + Console.WriteLine($"Terminated process {processId}"); + } + else + { + Console.WriteLine($"Process {processId} did not exit within timeout"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error killing process: {ex.Message}"); + } + finally + { + process.Dispose(); + } + } + } + + /// + /// Waits for an element to appear with the specified automation ID. + /// + /// + protected AutomationElement? WaitForElement(string automationId, TimeSpan timeout) + { + if (MainWindow == null) + { + return null; + } + + var startTime = DateTime.Now; + while (DateTime.Now - startTime < timeout) + { + try + { + var element = MainWindow.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + if (element != null) + { + return element; + } + } + catch + { + // Element not found yet + } + + Thread.Sleep(100); + } + + return null; + } + + /// + /// Takes a screenshot of the main window for debugging purposes. + /// + protected void TakeScreenshot(string filename) + { + if (MainWindow == null) + { + Console.WriteLine("Cannot take screenshot: MainWindow is null"); + return; + } + + try + { + var screenshotDir = Path.Combine(Directory.GetCurrentDirectory(), "Screenshots"); + Directory.CreateDirectory(screenshotDir); + + var screenshotPath = Path.Combine(screenshotDir, $"{filename}_{DateTime.Now:yyyyMMdd_HHmmss}.png"); + + // Get the window bounds + var bounds = MainWindow.BoundingRectangle; + Console.WriteLine($"Capturing window at: X={bounds.X}, Y={bounds.Y}, Width={bounds.Width}, Height={bounds.Height}"); + + // Capture the entire window area including title bar and borders + var screenshot = FlaUI.Core.Capturing.Capture.Rectangle(bounds); + + if (screenshot?.Bitmap != null) + { + screenshot.Bitmap.Save(screenshotPath, System.Drawing.Imaging.ImageFormat.Png); + Console.WriteLine($"Screenshot saved to: {screenshotPath}"); + Console.WriteLine($"Screenshot size: {screenshot.Bitmap.Width}x{screenshot.Bitmap.Height}"); + } + else + { + Console.WriteLine("Failed to capture screenshot: Bitmap is null"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to take screenshot: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + } + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs new file mode 100644 index 00000000..7fcf31a5 --- /dev/null +++ b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs @@ -0,0 +1,527 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Interop.UIAutomationClient; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; + +namespace AIDevGallery.Tests.TestInfra; + +/// +/// Base class for native UIA3-based UI tests. +/// Uses Windows native UIAutomation API directly without FlaUI. +/// +public abstract class NativeUIA3TestBase +{ + protected Process? AppProcess { get; private set; } + protected CUIAutomation? Automation { get; private set; } + protected IUIAutomationElement? MainWindow { get; private set; } + protected DateTime AppLaunchStartTime { get; private set; } + + /// + /// Gets the path to the AIDevGallery executable. + /// + /// + protected virtual string GetApplicationPath() + { + var solutionDir = FindSolutionDirectory(); + if (solutionDir == null) + { + throw new FileNotFoundException("Could not find solution directory"); + } + + var arch = Environment.Is64BitOperatingSystem ? "x64" : "x86"; + + var possiblePaths = new[] + { + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Debug", "net9.0-windows10.0.26100.0", $"win-{arch}", "AIDevGallery.exe"), + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Debug", "net9.0-windows10.0.26100.0", "AIDevGallery.exe"), + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Release", "net9.0-windows10.0.26100.0", $"win-{arch}", "AIDevGallery.exe"), + Path.Combine(solutionDir, "AIDevGallery", "bin", arch, "Release", "net9.0-windows10.0.26100.0", "AIDevGallery.exe"), + }; + + foreach (var path in possiblePaths) + { + if (File.Exists(path)) + { + Console.WriteLine($"Found application at: {path}"); + return path; + } + } + + var searchedPaths = string.Join(Environment.NewLine + " ", possiblePaths); + throw new FileNotFoundException( + $"Could not find AIDevGallery.exe at any expected location. Please build the application first.{Environment.NewLine}" + + $"Searched paths:{Environment.NewLine} {searchedPaths}"); + } + + /// + /// Finds the solution directory by walking up from the current directory. + /// + private static string? FindSolutionDirectory() + { + var currentDir = Directory.GetCurrentDirectory(); + while (currentDir != null) + { + if (File.Exists(Path.Combine(currentDir, "AIDevGallery.sln"))) + { + return currentDir; + } + + var parent = Directory.GetParent(currentDir); + currentDir = parent?.FullName; + } + + return null; + } + + /// + /// Try to get the package family name if the app is installed via MSIX. + /// + private static string? TryGetInstalledPackageFamilyName() + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -Command \"Get-AppxPackage | Where-Object {$_.Name -like '*e7af07c0-77d2-43e5-ab82-9cdb9daa11b3*'} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process == null) + { + return null; + } + + var output = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) + { + return output; + } + + if (!string.IsNullOrEmpty(error)) + { + Console.WriteLine($"Error querying packages: {error}"); + } + + return null; + } + catch (Exception ex) + { + Console.WriteLine($"Exception while checking for MSIX package: {ex.Message}"); + return null; + } + } + + /// + /// Find the AIDevGallery application process. + /// + private static Process? FindApplicationProcess() + { + for (int attempt = 0; attempt < 10; attempt++) + { + var processes = Process.GetProcessesByName("AIDevGallery"); + if (processes.Length > 0) + { + return processes.OrderByDescending(p => p.StartTime).First(); + } + + Thread.Sleep(500); + } + + return null; + } + + /// + /// Initializes the test by launching the application. + /// + [TestInitialize] + public virtual void TestInitialize() + { + // Create UIA automation object + Automation = new CUIAutomation(); + + // Close any existing instances + CloseExistingApplicationInstances(); + + // Record start time + AppLaunchStartTime = DateTime.UtcNow; + + // Launch the application + Console.WriteLine("Attempting to launch AIDevGallery..."); + + var packageFamilyName = TryGetInstalledPackageFamilyName(); + + if (!string.IsNullOrEmpty(packageFamilyName)) + { + Console.WriteLine($"Found installed MSIX package: {packageFamilyName}"); + Console.WriteLine("Launching via PowerShell..."); + + try + { + var appUserModelId = $"{packageFamilyName}!App"; + var psScript = $"Start-Process 'shell:AppsFolder\\{appUserModelId}'"; + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -WindowStyle Hidden -Command \"{psScript}\"", + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var psProcess = Process.Start(startInfo)) + { + psProcess?.WaitForExit(5000); + } + + Console.WriteLine("Waiting for application process to start..."); + Thread.Sleep(2000); + + AppProcess = FindApplicationProcess(); + if (AppProcess == null) + { + throw new InvalidOperationException("Application process not found after launch"); + } + + Console.WriteLine($"Found application process with PID: {AppProcess.Id}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to launch via MSIX: {ex.Message}"); + throw new InvalidOperationException( + $"Could not launch MSIX package: {packageFamilyName}{Environment.NewLine}" + + $"Error: {ex.Message}{Environment.NewLine}" + + $"Try launching the app manually from Start Menu to verify it works.", ex); + } + } + else + { + var appPath = GetApplicationPath(); + Console.WriteLine($"WARNING: No MSIX package found. Trying unpackaged exe: {appPath}"); + Console.WriteLine("This will likely fail with COM registration errors!"); + + try + { + AppProcess = Process.Start(appPath); + if (AppProcess == null) + { + throw new InvalidOperationException("Failed to start process"); + } + + Console.WriteLine($"Application launched with PID: {AppProcess.Id}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to launch unpackaged application: {ex.Message}"); + throw new InvalidOperationException( + $"Could not launch unpackaged application. WinUI3 requires MSIX deployment for testing.{Environment.NewLine}" + + $"Please run: msbuild AIDevGallery\\AIDevGallery.csproj /t:Deploy /p:Configuration=Debug /p:Platform=x64{Environment.NewLine}" + + $"See MSIX_DEPLOYMENT_REQUIRED.md for details.", ex); + } + } + + // Wait for the main window to appear + var timeout = TimeSpan.FromSeconds(60); + var startTime = DateTime.Now; + + Console.WriteLine("Waiting for main window to appear..."); + + while (MainWindow == null && DateTime.Now - startTime < timeout) + { + try + { + MainWindow = FindMainWindow(); + + if (MainWindow != null) + { + Console.WriteLine($"Main window found after {(DateTime.Now - startTime).TotalSeconds:F1} seconds"); + break; + } + } + catch (Exception ex) + { + var elapsed = DateTime.Now - startTime; + if (elapsed.TotalSeconds % 10 < 1) + { + Console.WriteLine($"Still waiting for window... ({elapsed.TotalSeconds:F0}s elapsed)"); + } + } + + Thread.Sleep(500); + } + + if (MainWindow == null) + { + var elapsed = DateTime.Now - startTime; + Console.WriteLine($"Failed to find main window after {elapsed.TotalSeconds:F1} seconds"); + throw new InvalidOperationException($"Failed to get main window within {timeout.TotalSeconds} seconds"); + } + + Console.WriteLine("Main window ready, waiting for UI initialization..."); + Thread.Sleep(2000); + } + + /// + /// Find the main window using native UIA3. + /// + private IUIAutomationElement? FindMainWindow() + { + if (Automation == null || AppProcess == null) + { + return null; + } + + try + { + // Get all windows from root + var rootElement = Automation.GetRootElement(); + var condition = Automation.CreatePropertyCondition(UIA_PropertyIds.UIA_ProcessIdPropertyId, AppProcess.Id); + var windows = rootElement.FindAll(TreeScope.TreeScope_Children, condition); + + if (windows == null || windows.Length == 0) + { + return null; + } + + // Return the first window found for this process + return windows.GetElement(0); + } + catch (COMException) + { + return null; + } + } + + /// + /// Cleans up after the test by closing the application. + /// + [TestCleanup] + public virtual void TestCleanup() + { + Console.WriteLine("Cleaning up test..."); + + try + { + if (MainWindow != null) + { + Console.WriteLine("Closing main window..."); + try + { + // Try to close window pattern if available + var windowPattern = MainWindow.GetCurrentPattern(UIA_PatternIds.UIA_WindowPatternId) as IUIAutomationWindowPattern; + windowPattern?.Close(); + } + catch (COMException ex) + { + Console.WriteLine($"Could not close window via pattern: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error closing main window: {ex.Message}"); + } + + try + { + if (AppProcess != null && !AppProcess.HasExited) + { + Console.WriteLine("Terminating application process..."); + AppProcess.Kill(entireProcessTree: true); + AppProcess.WaitForExit(5000); + AppProcess.Dispose(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error closing application: {ex.Message}"); + } + finally + { + try + { + // Release COM objects + if (MainWindow != null) + { + Marshal.ReleaseComObject(MainWindow); + MainWindow = null; + } + + if (Automation != null) + { + Marshal.ReleaseComObject(Automation); + Automation = null; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error releasing COM objects: {ex.Message}"); + } + + CloseExistingApplicationInstances(); + Console.WriteLine("Test cleanup completed"); + } + } + + /// + /// Closes any existing instances of AIDevGallery. + /// + private static void CloseExistingApplicationInstances() + { + var processes = Process.GetProcessesByName("AIDevGallery"); + if (processes.Length > 0) + { + Console.WriteLine($"Found {processes.Length} existing AIDevGallery process(es), terminating..."); + } + + foreach (var process in processes) + { + try + { + var processId = process.Id; + process.Kill(entireProcessTree: true); + var exited = process.WaitForExit(5000); + + if (exited) + { + Console.WriteLine($"Terminated process {processId}"); + } + else + { + Console.WriteLine($"Process {processId} did not exit within timeout"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error killing process: {ex.Message}"); + } + finally + { + process.Dispose(); + } + } + } + + /// + /// Waits for an element to appear with the specified automation ID. + /// + /// + protected IUIAutomationElement? WaitForElement(string automationId, TimeSpan timeout) + { + if (MainWindow == null || Automation == null) + { + return null; + } + + var startTime = DateTime.Now; + while (DateTime.Now - startTime < timeout) + { + try + { + var condition = Automation.CreatePropertyCondition(UIA_PropertyIds.UIA_AutomationIdPropertyId, automationId); + var element = MainWindow.FindFirst(TreeScope.TreeScope_Descendants, condition); + if (element != null) + { + return element; + } + } + catch (COMException) + { + // Element not found yet + } + + Thread.Sleep(100); + } + + return null; + } + + /// + /// Gets all descendant elements. + /// + /// + protected IUIAutomationElement[] GetAllDescendants() + { + if (MainWindow == null || Automation == null) + { + return Array.Empty(); + } + + try + { + var condition = Automation.CreateTrueCondition(); + var elements = MainWindow.FindAll(TreeScope.TreeScope_Descendants, condition); + + if (elements == null) + { + return Array.Empty(); + } + + var result = new IUIAutomationElement[elements.Length]; + for (int i = 0; i < elements.Length; i++) + { + result[i] = elements.GetElement(i); + } + + return result; + } + catch (COMException) + { + return Array.Empty(); + } + } + + /// + /// Takes a screenshot of the main window for debugging purposes. + /// + protected void TakeScreenshot(string filename) + { + if (MainWindow == null) + { + Console.WriteLine("Cannot take screenshot: MainWindow is null"); + return; + } + + try + { + var screenshotDir = Path.Combine(Directory.GetCurrentDirectory(), "Screenshots"); + Directory.CreateDirectory(screenshotDir); + + var screenshotPath = Path.Combine(screenshotDir, $"{filename}_{DateTime.Now:yyyyMMdd_HHmmss}.png"); + + // Get the window bounds + var rect = MainWindow.CurrentBoundingRectangle; + Console.WriteLine($"Capturing window at: X={rect.left}, Y={rect.top}, Width={rect.right - rect.left}, Height={rect.bottom - rect.top}"); + + // Use GDI+ to capture screenshot + var width = rect.right - rect.left; + var height = rect.bottom - rect.top; + + using var bitmap = new System.Drawing.Bitmap(width, height); + using var graphics = System.Drawing.Graphics.FromImage(bitmap); + graphics.CopyFromScreen(rect.left, rect.top, 0, 0, new System.Drawing.Size(width, height)); + bitmap.Save(screenshotPath, System.Drawing.Imaging.ImageFormat.Png); + + Console.WriteLine($"Screenshot saved to: {screenshotPath}"); + Console.WriteLine($"Screenshot size: {bitmap.Width}x{bitmap.Height}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to take screenshot: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + } + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs new file mode 100644 index 00000000..30d591e1 --- /dev/null +++ b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text.Json; + +namespace AIDevGallery.Tests.TestInfra; + +public class PerformanceReport +{ + public Metadata Meta { get; set; } = new(); + public EnvironmentInfo Environment { get; set; } = new(); + public List Measurements { get; set; } = new(); +} + +public class Metadata +{ + public string SchemaVersion { get; set; } = "1.0"; + public string RunId { get; set; } = string.Empty; + public string CommitHash { get; set; } = string.Empty; + public string Branch { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + public string Trigger { get; set; } = string.Empty; +} + +public class EnvironmentInfo +{ + public string OS { get; set; } = string.Empty; + public string Platform { get; set; } = string.Empty; + public string Configuration { get; set; } = string.Empty; + public HardwareInfo Hardware { get; set; } = new(); +} + +public class HardwareInfo +{ + public string Cpu { get; set; } = string.Empty; + public string Ram { get; set; } = string.Empty; + public string Gpu { get; set; } = string.Empty; +} + +public class Measurement +{ + public string Category { get; set; } = "General"; + public string Name { get; set; } = string.Empty; + public double Value { get; set; } + public string Unit { get; set; } = string.Empty; + public Dictionary? Tags { get; set; } +} + +/// +/// Performance metrics collector for tracking timing and memory usage during tests. +/// +/// Usage examples: +/// +/// 1. Manual timing with Stopwatch: +/// +/// var sw = Stopwatch.StartNew(); +/// // ... perform operation ... +/// sw.Stop(); +/// PerformanceCollector.Track("OperationTime", sw.ElapsedMilliseconds, "ms"); +/// +/// +/// 2. Automatic timing with using statement: +/// +/// using (PerformanceCollector.BeginTiming("OperationTime")) +/// { +/// // ... perform operation ... +/// } // Time automatically recorded here +/// +/// +/// 3. Memory tracking: +/// +/// PerformanceCollector.TrackMemoryUsage(processId, "MemoryUsage_Startup"); +/// PerformanceCollector.TrackCurrentProcessMemory("MemoryUsage_Current"); +/// +/// +public static class PerformanceCollector +{ + private static readonly List _measurements = new(); + private static readonly object _lock = new(); + + public static void Track(string name, double value, string unit, Dictionary? tags = null, string category = "General") + { + lock (_lock) + { + _measurements.Add(new Measurement + { + Category = category, + Name = name, + Value = value, + Unit = unit, + Tags = tags + }); + } + } + + public static string Save(string? outputDirectory = null) + { + List measurementsSnapshot; + lock (_lock) + { + measurementsSnapshot = new List(_measurements); + } + + var report = new PerformanceReport + { + Meta = new Metadata + { + // Support both GitHub Actions and Azure Pipelines variables + RunId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID") ?? Environment.GetEnvironmentVariable("BUILD_BUILDID") ?? "local-run", + CommitHash = Environment.GetEnvironmentVariable("GITHUB_SHA") ?? Environment.GetEnvironmentVariable("BUILD_SOURCEVERSION") ?? "local-sha", + Branch = Environment.GetEnvironmentVariable("GITHUB_REF_NAME") ?? Environment.GetEnvironmentVariable("BUILD_SOURCEBRANCHNAME") ?? "local-branch", + Timestamp = DateTime.UtcNow, + Trigger = Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") ?? Environment.GetEnvironmentVariable("BUILD_REASON") ?? "manual" + }, + Environment = new EnvironmentInfo + { + OS = System.Runtime.InteropServices.RuntimeInformation.OSDescription, + Platform = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(), +#if DEBUG + Configuration = "Debug", +#else + Configuration = "Release", +#endif + Hardware = GetHardwareInfo() + }, + Measurements = measurementsSnapshot + }; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(report, options); + + // Allow overriding output directory via environment variable (useful for CI) + string? envOutputDir = Environment.GetEnvironmentVariable("PERFORMANCE_OUTPUT_PATH"); + string dir = outputDirectory ?? envOutputDir ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "PerfResults"); + + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + string filename = $"perf-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid()}.json"; + string filePath = Path.Combine(dir, filename); + + File.WriteAllText(filePath, json); + Console.WriteLine($"Performance metrics saved to: {filePath}"); + + return filePath; + } + + public static void Clear() + { + lock (_lock) + { + _measurements.Clear(); + } + } + + /// + /// Tracks memory usage for a specific process. + /// + /// The process ID to measure. + /// The name of the metric (e.g., "MemoryUsage_Startup"). + /// Optional tags for categorization. + /// The category for this metric (default: "Memory"). + /// True if successful, false if measurement failed. + public static bool TrackMemoryUsage(int processId, string metricName, Dictionary? tags = null, string category = "Memory") + { + try + { + Console.WriteLine($"Attempting to measure memory for process ID: {processId}"); + var process = Process.GetProcessById(processId); + + // Refresh process info to get latest memory values + process.Refresh(); + + var memoryMB = process.PrivateMemorySize64 / 1024.0 / 1024.0; + var workingSetMB = process.WorkingSet64 / 1024.0 / 1024.0; + + Track(metricName, memoryMB, "MB", tags, category); + Console.WriteLine($"{metricName}: {memoryMB:F2} MB (Private), {workingSetMB:F2} MB (Working Set)"); + + // Also track working set as a separate metric + Track($"{metricName}_WorkingSet", workingSetMB, "MB", tags, category); + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: Could not measure memory for process {processId}"); + Console.WriteLine($"Exception type: {ex.GetType().Name}"); + Console.WriteLine($"Exception message: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + return false; + } + } + + /// + /// Tracks memory usage for the current process. + /// + /// The name of the metric (e.g., "MemoryUsage_Current"). + /// Optional tags for categorization. + /// The category for this metric (default: "Memory"). + /// True if successful, false if measurement failed. + public static bool TrackCurrentProcessMemory(string metricName, Dictionary? tags = null, string category = "Memory") + { + return TrackMemoryUsage(Process.GetCurrentProcess().Id, metricName, tags, category); + } + + /// + /// Creates a timing scope that automatically tracks elapsed time when disposed. + /// Use with 'using' statement for automatic timing. + /// + /// The name of the metric to track. + /// Optional tags for categorization. + /// The category for this metric (default: "Timing"). + /// A disposable timing scope. + public static IDisposable BeginTiming(string metricName, Dictionary? tags = null, string category = "Timing") + { + return new TimingScope(metricName, tags, category); + } + + private class TimingScope : IDisposable + { + private readonly Stopwatch _stopwatch; + private readonly string _metricName; + private readonly Dictionary? _tags; + private readonly string _category; + + public TimingScope(string metricName, Dictionary? tags, string category) + { + _metricName = metricName; + _tags = tags; + _category = category; + _stopwatch = Stopwatch.StartNew(); + } + + public void Dispose() + { + _stopwatch.Stop(); + Track(_metricName, _stopwatch.ElapsedMilliseconds, "ms", _tags, _category); + Console.WriteLine($"{_metricName}: {_stopwatch.ElapsedMilliseconds} ms"); + } + } + + private static HardwareInfo GetHardwareInfo() + { + var info = new HardwareInfo(); + + try + { + // Basic CPU info from environment if WMI fails or on non-Windows + info.Cpu = Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER") ?? "Unknown CPU"; + + // On Windows, we can try to get more details via WMI (System.Management) + // Note: This requires the System.Management NuGet package and Windows OS + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) + { + try + { + // Simple memory check + var gcMemoryInfo = GC.GetGCMemoryInfo(); + long totalMemoryBytes = gcMemoryInfo.TotalAvailableMemoryBytes; + info.Ram = $"{totalMemoryBytes / (1024 * 1024 * 1024)} GB"; + } + catch + { /* Ignore hardware detection errors */ + } + } + } + catch + { + // Fallback defaults + info.Cpu = "Unknown"; + info.Ram = "Unknown"; + } + + return info; + } +} \ No newline at end of file diff --git a/AIDevGallery.UnitTests/UnitTestApp.xaml b/AIDevGallery.Tests/TestInfra/UnitTestApp.xaml similarity index 92% rename from AIDevGallery.UnitTests/UnitTestApp.xaml rename to AIDevGallery.Tests/TestInfra/UnitTestApp.xaml index bfb26d8d..ed23540f 100644 --- a/AIDevGallery.UnitTests/UnitTestApp.xaml +++ b/AIDevGallery.Tests/TestInfra/UnitTestApp.xaml @@ -1,9 +1,9 @@ + xmlns:local="using:AIDevGallery.Tests"> diff --git a/AIDevGallery.UnitTests/UnitTestApp.xaml.cs b/AIDevGallery.Tests/TestInfra/UnitTestApp.xaml.cs similarity index 97% rename from AIDevGallery.UnitTests/UnitTestApp.xaml.cs rename to AIDevGallery.Tests/TestInfra/UnitTestApp.xaml.cs index 1b0cfee8..65fdc01a 100644 --- a/AIDevGallery.UnitTests/UnitTestApp.xaml.cs +++ b/AIDevGallery.Tests/TestInfra/UnitTestApp.xaml.cs @@ -5,7 +5,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; using System; -namespace AIDevGallery.UnitTests; +namespace AIDevGallery.Tests; /// /// Provides application-specific behavior to supplement the default Application class. diff --git a/AIDevGallery.UnitTests/UnitTestAppWindow.xaml b/AIDevGallery.Tests/TestInfra/UnitTestAppWindow.xaml similarity index 79% rename from AIDevGallery.UnitTests/UnitTestAppWindow.xaml rename to AIDevGallery.Tests/TestInfra/UnitTestAppWindow.xaml index 9e1466c8..15716882 100644 --- a/AIDevGallery.UnitTests/UnitTestAppWindow.xaml +++ b/AIDevGallery.Tests/TestInfra/UnitTestAppWindow.xaml @@ -1,9 +1,9 @@ diff --git a/AIDevGallery.UnitTests/UnitTestAppWindow.xaml.cs b/AIDevGallery.Tests/TestInfra/UnitTestAppWindow.xaml.cs similarity index 92% rename from AIDevGallery.UnitTests/UnitTestAppWindow.xaml.cs rename to AIDevGallery.Tests/TestInfra/UnitTestAppWindow.xaml.cs index 739ddc55..f43bec3a 100644 --- a/AIDevGallery.UnitTests/UnitTestAppWindow.xaml.cs +++ b/AIDevGallery.Tests/TestInfra/UnitTestAppWindow.xaml.cs @@ -3,7 +3,7 @@ using Microsoft.UI.Xaml; -namespace AIDevGallery.UnitTests; +namespace AIDevGallery.Tests; internal sealed partial class UnitTestAppWindow : Window { diff --git a/AIDevGallery.Tests/UITests/BasicInteractionTests.cs b/AIDevGallery.Tests/UITests/BasicInteractionTests.cs new file mode 100644 index 00000000..cf25e931 --- /dev/null +++ b/AIDevGallery.Tests/UITests/BasicInteractionTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Tests.TestInfra; +using FlaUI.Core.Input; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Linq; + +namespace AIDevGallery.Tests.UITests; + +/// +/// Sample UI tests demonstrating FlaUI basic operations. +/// These tests show how to interact with the AIDevGallery UI. +/// +[TestClass] +public class BasicInteractionTests : FlaUITestBase +{ + [TestMethod] + [TestCategory("UI")] + [TestCategory("Sample")] + [Description("Demonstrates how to find and log all clickable elements")] + public void Sample_FindAllClickableElements() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + // Act - Find all buttons and clickable elements + var buttons = MainWindow.FindAllDescendants(cf => + cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button)); + + var hyperlinks = MainWindow.FindAllDescendants(cf => + cf.ByControlType(FlaUI.Core.Definitions.ControlType.Hyperlink)); + + // Log findings + Console.WriteLine($"=== Clickable Elements Found ==="); + Console.WriteLine($"Buttons: {buttons.Length}"); + Console.WriteLine($"Hyperlinks: {hyperlinks.Length}"); + Console.WriteLine(); + + // Log button details + Console.WriteLine("=== Buttons ==="); + foreach (var button in buttons.Take(15)) + { + var name = string.IsNullOrEmpty(button.Name) ? "(no name)" : button.Name; + string automationId; + try + { + automationId = string.IsNullOrEmpty(button.AutomationId) ? "(no id)" : button.AutomationId; + } + catch (FlaUI.Core.Exceptions.PropertyNotSupportedException) + { + automationId = "(not supported)"; + } + Console.WriteLine($" - {name} [ID: {automationId}]"); + } + + if (buttons.Length > 15) + { + Console.WriteLine($" ... and {buttons.Length - 15} more"); + } + + // Take screenshot + TakeScreenshot("Sample_ClickableElements"); + + // Assert + Assert.IsTrue( + buttons.Length > 0 || hyperlinks.Length > 0, + "Should find at least some clickable elements"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Sample")] + [Description("Demonstrates how to search for elements by name")] + public void Sample_SearchElementsByName() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + // Act - Search for common UI element names + string[] searchTerms = { "Settings", "Home", "Search", "Menu", "Back", "Close" }; + + Console.WriteLine("=== Searching for Common UI Elements ==="); + foreach (var term in searchTerms) + { + var elements = MainWindow.FindAllDescendants(cf => cf.ByName(term)); + if (elements.Length > 0) + { + Console.WriteLine($"Found '{term}': {elements.Length} element(s)"); + foreach (var element in elements.Take(3)) + { + Console.WriteLine($" - Type: {element.ControlType}, Enabled: {element.IsEnabled}"); + } + } + else + { + Console.WriteLine($"'{term}': Not found"); + } + } + + TakeScreenshot("Sample_SearchByName"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Sample")] + [Description("Demonstrates how to use keyboard input")] + public void Sample_KeyboardInput() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + // Make sure the window has focus + MainWindow.Focus(); + System.Threading.Thread.Sleep(500); + + // Act - Send keyboard input + Console.WriteLine("=== Keyboard Input Demo ==="); + Console.WriteLine("Sending Tab key to navigate..."); + + // Send Tab key a few times + Keyboard.Type(FlaUI.Core.WindowsAPI.VirtualKeyShort.TAB); + System.Threading.Thread.Sleep(300); + + Keyboard.Type(FlaUI.Core.WindowsAPI.VirtualKeyShort.TAB); + System.Threading.Thread.Sleep(300); + + Console.WriteLine("Keyboard input sent successfully"); + TakeScreenshot("Sample_KeyboardInput"); + + // Assert - Just verify the window is still responsive + Assert.IsTrue(MainWindow.IsAvailable, "Window should still be available after keyboard input"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Sample")] + [Description("Demonstrates how to count different element types")] + public void Sample_CountElementTypes() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + // Act - Count different element types + var allElements = MainWindow.FindAllDescendants(); + + var elementTypeCounts = allElements + .GroupBy(e => e.ControlType.ToString()) + .Select(g => new { Type = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .ToList(); + + // Log findings + Console.WriteLine("=== Element Type Statistics ==="); + Console.WriteLine($"Total elements: {allElements.Length}"); + Console.WriteLine(); + Console.WriteLine("Breakdown by type:"); + + foreach (var item in elementTypeCounts) + { + Console.WriteLine($" {item.Type}: {item.Count}"); + } + + TakeScreenshot("Sample_ElementTypes"); + + // Assert + Assert.IsTrue(allElements.Length > 0, "Should find UI elements"); + Assert.IsTrue(elementTypeCounts.Count > 0, "Should have multiple element types"); + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UITests/MainWindowTests.cs b/AIDevGallery.Tests/UITests/MainWindowTests.cs new file mode 100644 index 00000000..52e0a58c --- /dev/null +++ b/AIDevGallery.Tests/UITests/MainWindowTests.cs @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Tests.TestInfra; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Tools; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Linq; + +namespace AIDevGallery.Tests.UITests; + +/// +/// Basic UI tests for the AIDevGallery main window. +/// +[TestClass] +public class MainWindowTests : FlaUITestBase +{ + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that the main window launches successfully")] + public void MainWindow_Launches_Successfully() + { + // Assert + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + Assert.IsFalse(string.IsNullOrEmpty(MainWindow.Title), "Main window should have a title"); + + Console.WriteLine($"Main window title: {MainWindow.Title}"); + + // Take a screenshot for verification + TakeScreenshot("MainWindow_Launch"); + } + + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that the main window is visible and not minimized")] + public void MainWindow_IsVisible() + { + // Assert + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + Assert.IsTrue(MainWindow.IsAvailable, "Main window should be available"); + + var patterns = MainWindow.Patterns; + Console.WriteLine($"Main window is available: {MainWindow.IsAvailable}"); + Console.WriteLine($"Main window is offscreen: {MainWindow.IsOffscreen}"); + } + + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that the main window can be resized")] + public void MainWindow_CanBeResized() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + var originalBounds = MainWindow.BoundingRectangle; + Console.WriteLine($"Original bounds: W={originalBounds.Width}, H={originalBounds.Height}"); + + // Act - Try to resize the window (if supported) + try + { + if (MainWindow.Patterns.Transform.IsSupported) + { + var transform = MainWindow.Patterns.Transform.Pattern; + if (transform.CanResize) + { + // Try a larger size first to ensure change is detectable + double newWidth = originalBounds.Width + 200; + double newHeight = originalBounds.Height + 100; + + Console.WriteLine($"Attempting to resize to: W={newWidth}, H={newHeight}"); + transform.Resize(newWidth, newHeight); + System.Threading.Thread.Sleep(500); // Wait for resize + + var newBounds = MainWindow.BoundingRectangle; + Console.WriteLine($"New bounds after resize: W={newBounds.Width}, H={newBounds.Height}"); + + // Take screenshot after resize + TakeScreenshot("MainWindow_AfterResize"); + + // Check if size changed at all (width OR height) + bool sizeChanged = Math.Abs(originalBounds.Width - newBounds.Width) > 10 || + Math.Abs(originalBounds.Height - newBounds.Height) > 10; + + if (sizeChanged) + { + // Assert - window was resized successfully + Assert.IsTrue(sizeChanged, "Window size should have changed"); + Console.WriteLine("✓ Window resize successful"); + } + else + { + // Window might have size constraints, mark as inconclusive + Assert.Inconclusive($"Window size did not change (possible size constraints). Original: {originalBounds.Width}x{originalBounds.Height}, After: {newBounds.Width}x{newBounds.Height}"); + } + } + else + { + Assert.Inconclusive("Window does not support resizing (CanResize = false)"); + } + } + else + { + Assert.Inconclusive("Transform pattern not supported"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Resize test inconclusive: {ex.Message}"); + Assert.Inconclusive($"Could not test resize functionality: {ex.Message}"); + } + } + + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that the main window contains UI elements")] + public void MainWindow_ContainsUIElements() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + // Act - Find all descendants + var allElements = MainWindow.FindAllDescendants(); + + // Assert + Assert.IsNotNull(allElements, "Should be able to query descendants"); + Assert.IsTrue(allElements.Length > 0, "Main window should contain UI elements"); + + Console.WriteLine($"Found {allElements.Length} UI elements in the main window"); + + // Log some element types for debugging + var elementTypes = allElements + .Select(e => e.ControlType.ToString()) + .Distinct() + .OrderBy(t => t) + .ToList(); + + Console.WriteLine($"Element types found: {string.Join(", ", elementTypes)}"); + + TakeScreenshot("MainWindow_UIElements"); + } + + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that buttons can be found in the main window")] + public void MainWindow_ContainsButtons() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + // Act - Find all buttons + var buttons = MainWindow.FindAllDescendants(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button)); + + // Assert + Assert.IsNotNull(buttons, "Should be able to query buttons"); + Console.WriteLine($"Found {buttons.Length} buttons in the main window"); + + if (buttons.Length > 0) + { + // Log button names for debugging + for (int i = 0; i < Math.Min(buttons.Length, 10); i++) + { + var button = buttons[i]; + try + { + var automationId = button.Properties.AutomationId.IsSupported + ? button.AutomationId + : "(not supported)"; + Console.WriteLine($"Button {i + 1}: Name='{button.Name}', AutomationId='{automationId}'"); + } + catch (FlaUI.Core.Exceptions.PropertyNotSupportedException) + { + Console.WriteLine($"Button {i + 1}: Name='{button.Name}', AutomationId=(not supported)"); + } + } + } + } + + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that the main window can be closed")] + public void MainWindow_CanBeClosed() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + Assert.IsTrue(MainWindow.IsAvailable, "Main window should be available before closing"); + + // Act + MainWindow.Close(); + System.Threading.Thread.Sleep(1000); // Wait for close + + // Assert + try + { + var isStillAvailable = MainWindow.IsAvailable; + Assert.IsFalse(isStillAvailable, "Main window should not be available after closing"); + } + catch + { + // If accessing IsAvailable throws, the window is definitely closed + Assert.IsTrue(true, "Window was closed successfully"); + } + } + + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that text elements can be found in the main window")] + public void MainWindow_ContainsTextElements() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + + // Act - Find all text elements + var textElements = MainWindow.FindAllDescendants(cf => + cf.ByControlType(FlaUI.Core.Definitions.ControlType.Text)); + + // Assert + Assert.IsNotNull(textElements, "Should be able to query text elements"); + Console.WriteLine($"Found {textElements.Length} text elements in the main window"); + + if (textElements.Length > 0) + { + // Log some text content for debugging + for (int i = 0; i < Math.Min(textElements.Length, 10); i++) + { + var textElement = textElements[i]; + var text = textElement.Name; + if (!string.IsNullOrWhiteSpace(text)) + { + Console.WriteLine($"Text {i + 1}: '{text}'"); + } + } + } + + TakeScreenshot("MainWindow_TextElements"); + } + + [TestMethod] + [TestCategory("UI")] + [Description("Verifies that the search box accepts input and displays search results")] + public void SearchBox_DisplaysResults_WhenQueryEntered() + { + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + +// pane 'Desktop 1' +// - windows 'AI Dev Gallery Dev' +// - pane '' +// - pane '' +// - title bar 'AI Dev Gallery' (AutomationId="titleBar") +// - group '' (AutomationId="SearchBox") +// - edit 'Name Search samples, models & APIs..'(AutomationId="TextBox") + var searchBoxGroupResult = Retry.WhileNull( + () => MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("SearchBox")), + timeout: TimeSpan.FromSeconds(10)); + var searchBoxGroup = searchBoxGroupResult.Result; + Assert.IsNotNull(searchBoxGroup, "Search box group not found"); + + var searchBox = searchBoxGroup.FindFirstDescendant(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Edit)); + Assert.IsNotNull(searchBox, "Search box text input not found"); + + Console.WriteLine("Search box found, entering search query..."); + + searchBox.AsTextBox().Text = "Phi"; + Console.WriteLine("Search query 'Phi' entered"); + +// pane 'Desktop 1' +// - windows 'AI Dev Gallery Dev' +// - pane 'PopupHost' +// - pane '' +// - title bar 'AI Dev Gallery' (AutomationId="titleBar") +// - group 'SearchBox' (AutomationId="SearchBox") +// - window 'Popup' (AutomationId="SuggestionsPopup") +// - list '' (AutomationId="SuggestionsList") // length needs to be > 0 +// - list item 'Phi 3 Medium' +// - list item ... +// - ... + var suggestionsPopupResult = Retry.WhileNull( + () => searchBoxGroup.FindFirstDescendant(cf => cf.ByAutomationId("SuggestionsPopup")), + timeout: TimeSpan.FromSeconds(5)); + var suggestionsPopup = suggestionsPopupResult.Result; + Assert.IsNotNull(suggestionsPopup, "Suggestions popup should appear after entering query"); + + var suggestionsListResult = Retry.WhileNull( + () => suggestionsPopup.FindFirstDescendant(cf => cf.ByAutomationId("SuggestionsList")), + timeout: TimeSpan.FromSeconds(5)); + var suggestionsList = suggestionsListResult.Result; + Assert.IsNotNull(suggestionsList, "Suggestions list should be found in popup"); + + var listItems = suggestionsList.FindAllChildren(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.ListItem)); + + Assert.IsTrue(listItems.Length > 0, $"Suggestions list should contain search results, but found {listItems.Length} items"); + Console.WriteLine($"Search results displayed. Found {listItems.Length} suggestions"); + + for (int i = 0; i < Math.Min(listItems.Length, 3); i++) + { + Console.WriteLine($" Result {i + 1}: {listItems[i].Name}"); + } + + TakeScreenshot("SearchBox_WithResults"); + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs b/AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs new file mode 100644 index 00000000..0d7d660a --- /dev/null +++ b/AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Tests.TestInfra; +using Interop.UIAutomationClient; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +namespace AIDevGallery.Tests.UITests; + +/// +/// Smoke tests using native Windows UIA3 API without FlaUI dependency. +/// These tests verify basic application functionality using COM-based UIAutomation. +/// +[TestClass] +public class NativeUIA3SmokeTests : NativeUIA3TestBase +{ + [TestMethod] + [TestCategory("UI")] + [TestCategory("Smoke")] + [TestCategory("NativeUIA3")] + [Description("Verifies that the application can be found and launched using native UIA3")] + public void NativeUIA3_ApplicationLaunches() + { + // The TestInitialize already launched the app and got the main window + Assert.IsNotNull(AppProcess, "Application process should be launched"); + Assert.IsNotNull(Automation, "UIA Automation should be initialized"); + Assert.IsNotNull(MainWindow, "Main window should be available"); + + Console.WriteLine("✓ Application launched successfully"); + Console.WriteLine($"✓ Process ID: {AppProcess.Id}"); + + try + { + var windowName = MainWindow.CurrentName; + Console.WriteLine($"✓ Main window title: {windowName}"); + } + catch (COMException ex) + { + Console.WriteLine($"Could not get window name: {ex.Message}"); + } + + TakeScreenshot("NativeUIA3_ApplicationLaunched"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Smoke")] + [TestCategory("NativeUIA3")] + [Description("Verifies that the main window has basic properties using native UIA3")] + public void NativeUIA3_MainWindowHasBasicProperties() + { + Assert.IsNotNull(MainWindow, "Main window should exist"); + + // Check basic window properties + try + { + var windowName = MainWindow.CurrentName; + Assert.IsFalse(string.IsNullOrEmpty(windowName), "Window should have a name"); + Console.WriteLine($"✓ Window name: {windowName}"); + } + catch (COMException ex) + { + Console.WriteLine($"Could not get window name: {ex.Message}"); + } + + try + { + var bounds = MainWindow.CurrentBoundingRectangle; + var width = bounds.right - bounds.left; + var height = bounds.bottom - bounds.top; + + Assert.IsTrue(width > 0, "Window should have width"); + Assert.IsTrue(height > 0, "Window should have height"); + + Console.WriteLine("✓ Main window has valid properties"); + Console.WriteLine($" Size: {width}x{height}"); + Console.WriteLine($" Position: ({bounds.left}, {bounds.top})"); + } + catch (COMException ex) + { + Assert.Fail($"Could not get window bounds: {ex.Message}"); + } + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Smoke")] + [TestCategory("NativeUIA3")] + [Description("Verifies that UI elements can be queried using native UIA3")] + public void NativeUIA3_CanQueryUIElements() + { + Assert.IsNotNull(MainWindow, "Main window should exist"); + + // Try to get all descendants + var allElements = GetAllDescendants(); + + Assert.IsNotNull(allElements, "Should be able to query elements"); + Assert.IsTrue(allElements.Length > 0, "Should find at least some UI elements"); + + Console.WriteLine($"✓ Found {allElements.Length} UI elements"); + + // Count element types + var uniqueTypes = new HashSet(); + foreach (var element in allElements) + { + try + { + var controlType = element.CurrentControlType; + uniqueTypes.Add(controlType); + } + catch (COMException) + { + // Skip elements that can't be queried + } + } + + Console.WriteLine($"✓ Found {uniqueTypes.Count} different control types"); + + // Release COM objects + foreach (var element in allElements) + { + try + { + Marshal.ReleaseComObject(element); + } + catch + { + // Ignore cleanup errors + } + } + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Sample")] + [TestCategory("NativeUIA3")] + [Description("Logs details for elements that expose AutomationId values using native UIA3")] + public void NativeUIA3_LogAutomationIds() + { + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + Assert.IsNotNull(Automation, "Automation should be initialized"); + + Console.WriteLine("=== Elements Exposing AutomationId (Native UIA3) ==="); + var allElements = GetAllDescendants(); + var elementsWithId = new List<(IUIAutomationElement Element, string AutomationId)>(); + var elementsWithoutId = new List(); + + foreach (var element in allElements) + { + try + { + var automationId = element.CurrentAutomationId; + if (string.IsNullOrWhiteSpace(automationId)) + { + elementsWithoutId.Add(element); + continue; + } + + elementsWithId.Add((element, automationId)); + } + catch (COMException) + { + elementsWithoutId.Add(element); + } + } + + Console.WriteLine($"Total elements scanned: {allElements.Length}"); + Console.WriteLine($"Elements with AutomationId: {elementsWithId.Count}"); + Console.WriteLine($"Elements without AutomationId: {elementsWithoutId.Count}"); + + static string GetControlTypeName(int controlType) + { + return controlType switch + { + UIA_ControlTypeIds.UIA_ButtonControlTypeId => "Button", + UIA_ControlTypeIds.UIA_TextControlTypeId => "Text", + UIA_ControlTypeIds.UIA_EditControlTypeId => "Edit", + UIA_ControlTypeIds.UIA_WindowControlTypeId => "Window", + UIA_ControlTypeIds.UIA_PaneControlTypeId => "Pane", + UIA_ControlTypeIds.UIA_ListControlTypeId => "List", + UIA_ControlTypeIds.UIA_ListItemControlTypeId => "ListItem", + UIA_ControlTypeIds.UIA_ImageControlTypeId => "Image", + UIA_ControlTypeIds.UIA_GroupControlTypeId => "Group", + UIA_ControlTypeIds.UIA_CheckBoxControlTypeId => "CheckBox", + UIA_ControlTypeIds.UIA_ComboBoxControlTypeId => "ComboBox", + UIA_ControlTypeIds.UIA_ScrollBarControlTypeId => "ScrollBar", + UIA_ControlTypeIds.UIA_HyperlinkControlTypeId => "Hyperlink", + UIA_ControlTypeIds.UIA_MenuControlTypeId => "Menu", + UIA_ControlTypeIds.UIA_MenuItemControlTypeId => "MenuItem", + UIA_ControlTypeIds.UIA_ToolBarControlTypeId => "ToolBar", + UIA_ControlTypeIds.UIA_TabControlTypeId => "Tab", + UIA_ControlTypeIds.UIA_TabItemControlTypeId => "TabItem", + _ => $"Unknown({controlType})" + }; + } + + static string DescribeName(IUIAutomationElement element) + { + try + { + var name = element.CurrentName; + return string.IsNullOrEmpty(name) ? "(no name)" : name; + } + catch (COMException) + { + return "(name not accessible)"; + } + } + + static string DescribeClass(IUIAutomationElement element) + { + try + { + var className = element.CurrentClassName; + return string.IsNullOrEmpty(className) ? "(no class)" : className; + } + catch (COMException) + { + return "(class not accessible)"; + } + } + + static string DescribeFramework(IUIAutomationElement element) + { + try + { + var framework = element.CurrentFrameworkId; + return string.IsNullOrEmpty(framework) ? "(no framework)" : framework; + } + catch (COMException) + { + return "(framework not accessible)"; + } + } + + static string DescribeIsEnabled(IUIAutomationElement element) + { + try + { + return element.CurrentIsEnabled != 0 ? "true" : "false"; + } + catch (COMException) + { + return "(IsEnabled not accessible)"; + } + } + + static string DescribeBounds(IUIAutomationElement element) + { + try + { + var bounds = element.CurrentBoundingRectangle; + var width = bounds.right - bounds.left; + var height = bounds.bottom - bounds.top; + return $"[{bounds.left}, {bounds.top}, {width}x{height}]"; + } + catch (COMException) + { + return "(bounds not accessible)"; + } + } + + Console.WriteLine(); + Console.WriteLine("--- Elements WITH AutomationId ---"); + foreach (var elementInfo in elementsWithId.Take(50)) + { + try + { + var controlType = GetControlTypeName(elementInfo.Element.CurrentControlType); + var name = DescribeName(elementInfo.Element); + var className = DescribeClass(elementInfo.Element); + var framework = DescribeFramework(elementInfo.Element); + var isEnabled = DescribeIsEnabled(elementInfo.Element); + var bounds = DescribeBounds(elementInfo.Element); + + Console.WriteLine($" - ControlType='{controlType}', AutomationId='{elementInfo.AutomationId}', Name='{name}', Class='{className}', Framework='{framework}', IsEnabled={isEnabled}, Bounds={bounds}"); + } + catch (Exception ex) + { + Console.WriteLine($" - Error describing element: {ex.Message}"); + } + } + + if (elementsWithId.Count > 50) + { + Console.WriteLine($" ... and {elementsWithId.Count - 50} more with AutomationId"); + } + + Console.WriteLine(); + Console.WriteLine("--- Elements WITHOUT AutomationId ---"); + foreach (var element in elementsWithoutId.Take(50)) + { + try + { + var controlType = GetControlTypeName(element.CurrentControlType); + var name = DescribeName(element); + var className = DescribeClass(element); + var framework = DescribeFramework(element); + var isEnabled = DescribeIsEnabled(element); + var bounds = DescribeBounds(element); + + Console.WriteLine($" - ControlType='{controlType}', Name='{name}', Class='{className}', Framework='{framework}', IsEnabled={isEnabled}, Bounds={bounds}"); + } + catch (Exception ex) + { + Console.WriteLine($" - Error describing element: {ex.Message}"); + } + } + + if (elementsWithoutId.Count > 50) + { + Console.WriteLine($" ... and {elementsWithoutId.Count - 50} more without AutomationId"); + } + + // Clean up COM objects + foreach (var elementInfo in elementsWithId) + { + try + { + Marshal.ReleaseComObject(elementInfo.Element); + } + catch + { + // Ignore cleanup errors + } + } + + foreach (var element in elementsWithoutId) + { + try + { + Marshal.ReleaseComObject(element); + } + catch + { + // Ignore cleanup errors + } + } + + Assert.IsTrue(true, "This sample is intended only for logging AutomationId information"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Sample")] + [TestCategory("NativeUIA3")] + [Description("Tests finding specific elements by AutomationId using native UIA3")] + public void NativeUIA3_FindElementByAutomationId() + { + Assert.IsNotNull(MainWindow, "Main window should exist"); + Assert.IsNotNull(Automation, "Automation should be initialized"); + + // Try to find some common elements + var testAutomationIds = new[] { "NavigationView", "SettingsButton", "SearchBox" }; + + Console.WriteLine("=== Testing Element Lookup by AutomationId ==="); + + foreach (var automationId in testAutomationIds) + { + Console.WriteLine($"Looking for element with AutomationId: {automationId}"); + + try + { + var condition = Automation.CreatePropertyCondition( + UIA_PropertyIds.UIA_AutomationIdPropertyId, + automationId); + + var element = MainWindow.FindFirst(TreeScope.TreeScope_Descendants, condition); + + if (element != null) + { + Console.WriteLine($" ✓ Found element: {automationId}"); + + try + { + var name = element.CurrentName; + var controlType = element.CurrentControlType; + Console.WriteLine($" Name: {name}"); + Console.WriteLine($" ControlType: {controlType}"); + } + catch (COMException ex) + { + Console.WriteLine($" Could not get element details: {ex.Message}"); + } + + Marshal.ReleaseComObject(element); + } + else + { + Console.WriteLine($" ✗ Element not found: {automationId}"); + } + } + catch (COMException ex) + { + Console.WriteLine($" ✗ Error searching for {automationId}: {ex.Message}"); + } + } + + // This test is informational, so always pass + Assert.IsTrue(true, "Element search test completed"); + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs new file mode 100644 index 00000000..4dc0d227 --- /dev/null +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Tests.TestInfra; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Linq; +using System.Threading; + +namespace AIDevGallery.Tests.UITests; + +/// +/// UI tests for NavigationView interactions using FlaUI. +/// +[TestClass] +public class NavigationViewTests : FlaUITestBase +{ + public TestContext? TestContext { get; set; } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Navigation")] + [Description("Test clicking all NavigationView items")] + public void NavigationView_ClickAllLeftMenuHostItems() + { + // Arrange + Assert.IsNotNull(MainWindow, "Main window should be initialized"); + Console.WriteLine("Starting test: Click all navigation items"); + + Thread.Sleep(1000); + + // Act - Find the MenuItemsHost to get only top-level navigation items + var menuItemsHost = MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHost")); + + Assert.IsNotNull(menuItemsHost, "MenuItemsHost should be found"); + + // Find only DIRECT children ListItems, not all descendants + // This prevents getting nested navigation items from inner NavigationViews + var navigationItems = menuItemsHost.FindAllChildren(cf => + cf.ByControlType(ControlType.ListItem)) + .Where(item => item.IsEnabled && item.IsOffscreen == false) + .ToArray(); + + Console.WriteLine($"Found {navigationItems.Length} enabled navigation items"); + + Assert.IsTrue(navigationItems.Length > 0, "Should have at least one navigation item"); + + // Click each item + foreach (var item in navigationItems) + { + Console.WriteLine($"\nClicking navigation item: {item.Name}"); + + try + { + item.Click(); + Thread.Sleep(1000); + + var screenshotName = $"NavigationView_Item_{item.Name?.Replace(" ", "_") ?? "Unknown"}"; + TakeScreenshot(screenshotName); + + Console.WriteLine($"Successfully clicked: {item.Name}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to click item {item.Name}: {ex.Message}"); + } + } + + // Assert + Console.WriteLine("\nNavigation item clicking test completed"); + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UITests/SmokeTests.cs b/AIDevGallery.Tests/UITests/SmokeTests.cs new file mode 100644 index 00000000..1fabc092 --- /dev/null +++ b/AIDevGallery.Tests/UITests/SmokeTests.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Tests.TestInfra; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AIDevGallery.Tests.UITests; + +/// +/// Quick smoke tests to verify FlaUI setup is working. +/// These tests run fast and help diagnose setup issues. +/// +[TestClass] +public class SmokeTests : FlaUITestBase +{ + [TestMethod] + [TestCategory("UI")] + [TestCategory("Smoke")] + [Description("Verifies that the application can be found and launched")] + public void Smoke_ApplicationLaunches() + { + // The TestInitialize already launched the app and got the main window + // This test just verifies that setup worked + Assert.IsNotNull(App, "Application should be launched"); + Assert.IsNotNull(Automation, "Automation should be initialized"); + Assert.IsNotNull(MainWindow, "Main window should be available"); + + Console.WriteLine("✓ Application launched successfully"); + Console.WriteLine($"✓ Process ID: {App.ProcessId}"); + Console.WriteLine($"✓ Main window title: {MainWindow.Title}"); + + TakeScreenshot("Smoke_ApplicationLaunched"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Smoke")] + [Description("Verifies that the main window has basic properties")] + public void Smoke_MainWindowHasBasicProperties() + { + Assert.IsNotNull(MainWindow, "Main window should exist"); + + // Check basic window properties + Assert.IsTrue(MainWindow.IsAvailable, "Window should be available"); + Assert.IsFalse(string.IsNullOrEmpty(MainWindow.Title), "Window should have a title"); + + var bounds = MainWindow.BoundingRectangle; + Assert.IsTrue(bounds.Width > 0, "Window should have width"); + Assert.IsTrue(bounds.Height > 0, "Window should have height"); + + Console.WriteLine("✓ Main window has valid properties"); + Console.WriteLine($" Title: {MainWindow.Title}"); + Console.WriteLine($" Size: {bounds.Width}x{bounds.Height}"); + Console.WriteLine($" Position: ({bounds.X}, {bounds.Y})"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Smoke")] + [Description("Verifies that UI elements can be queried")] + public void Smoke_CanQueryUIElements() + { + Assert.IsNotNull(MainWindow, "Main window should exist"); + + // Try to get all descendants + var allElements = MainWindow.FindAllDescendants(); + + Assert.IsNotNull(allElements, "Should be able to query elements"); + Assert.IsTrue(allElements.Length > 0, "Should find at least some UI elements"); + + Console.WriteLine($"✓ Found {allElements.Length} UI elements"); + + // Count element types + var uniqueTypes = new System.Collections.Generic.HashSet(); + foreach (var element in allElements) + { + uniqueTypes.Add(element.ControlType.ToString()); + } + + Console.WriteLine($"✓ Found {uniqueTypes.Count} different element types"); + } + + [TestMethod] + [TestCategory("UI")] + [TestCategory("Sample")] + [Description("Logs details for elements that expose AutomationId values")] + public void Sample_LogAutomationIds() + { + var window = MainWindow ?? throw new InvalidOperationException("Main window should be initialized"); + + Console.WriteLine("=== Elements Exposing AutomationId ==="); + var allElements = window.FindAllDescendants(); + var elementsWithId = new List<(FlaUI.Core.AutomationElements.AutomationElement Element, string AutomationId)>(); + var elementsWithoutId = new List(); + var unsupportedCount = 0; + + foreach (var element in allElements) + { + if (!element.Properties.AutomationId.IsSupported) + { + unsupportedCount++; + elementsWithoutId.Add(element); + continue; + } + + var automationId = element.AutomationId; + if (string.IsNullOrWhiteSpace(automationId)) + { + elementsWithoutId.Add(element); + continue; + } + + elementsWithId.Add((element, automationId)); + } + + Console.WriteLine($"Total elements scanned: {allElements.Length}"); + Console.WriteLine($"Elements with AutomationId: {elementsWithId.Count}"); + Console.WriteLine($"Elements without AutomationId value: {elementsWithoutId.Count}"); + Console.WriteLine($"Elements lacking AutomationId support: {unsupportedCount}"); + + static string DescribeName(FlaUI.Core.AutomationElements.AutomationElement element) + { + if (!element.Properties.Name.IsSupported) + { + return "(name property not supported)"; + } + + var rawName = element.Name; + return string.IsNullOrEmpty(rawName) ? "(no name)" : rawName; + } + + static string DescribeClass(FlaUI.Core.AutomationElements.AutomationElement element) + { + if (!element.Properties.ClassName.IsSupported) + { + return "(class property not supported)"; + } + + var rawClass = element.ClassName; + return string.IsNullOrEmpty(rawClass) ? "(no class)" : rawClass; + } + + static string DescribeFramework(FlaUI.Core.AutomationElements.AutomationElement element) + { + if (!element.Properties.FrameworkId.IsSupported) + { + return "(framework property not supported)"; + } + + var rawFramework = element.Properties.FrameworkId.ValueOrDefault; + return string.IsNullOrEmpty(rawFramework) ? "(no framework)" : rawFramework; + } + + static string DescribeIsEnabled(FlaUI.Core.AutomationElements.AutomationElement element) + { + if (!element.Properties.IsEnabled.IsSupported) + { + return "(IsEnabled not supported)"; + } + + return element.IsEnabled ? "true" : "false"; + } + + static string DescribeBounds(FlaUI.Core.AutomationElements.AutomationElement element) + { + if (!element.Properties.BoundingRectangle.IsSupported) + { + return "(bounds not supported)"; + } + + var bounds = element.BoundingRectangle; + return $"[{bounds.Left}, {bounds.Top}, {bounds.Width}x{bounds.Height}]"; + } + + foreach (var elementInfo in elementsWithId.Take(50)) + { + var name = DescribeName(elementInfo.Element); + var className = DescribeClass(elementInfo.Element); + var framework = DescribeFramework(elementInfo.Element); + var isEnabled = DescribeIsEnabled(elementInfo.Element); + var bounds = DescribeBounds(elementInfo.Element); + + Console.WriteLine($" - ControlType='{elementInfo.Element.ControlType}', AutomationId='{elementInfo.AutomationId}',Name='{name}', Class='{className}', Framework='{framework}', Bounds={bounds}"); + } + + if (elementsWithId.Count > 50) + { + Console.WriteLine($" ... and {elementsWithId.Count - 50} more with AutomationId"); + } + + Console.WriteLine(); + Console.WriteLine("================= Elements Without AutomationId ==="); + foreach (var element in elementsWithoutId.Take(50)) + { + var name = DescribeName(element); + var className = DescribeClass(element); + var framework = DescribeFramework(element); + var isEnabled = DescribeIsEnabled(element); + var bounds = DescribeBounds(element); + + Console.WriteLine($" - ControlType='{element.ControlType}', Name='{name}', Class='{className}', Framework='{framework}', Bounds={bounds}"); + } + + if (elementsWithoutId.Count > 50) + { + Console.WriteLine($" ... and {elementsWithoutId.Count - 50} more without AutomationId"); + } + + Assert.IsTrue(true, "This sample is intended only for logging AutomationId information"); + } +} \ No newline at end of file diff --git a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs new file mode 100644 index 00000000..c0f46bb5 --- /dev/null +++ b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AIDevGallery.Models; +using AIDevGallery.Utils; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; + +namespace AIDevGallery.Tests.Unit; + +[TestClass] +public class AppUtilsTests +{ + [TestMethod] + public void FileSizeToString_ConvertsCorrectly() + { + Assert.AreEqual("1.0KB", AppUtils.FileSizeToString(1024)); + Assert.AreEqual("1.0MB", AppUtils.FileSizeToString(1024 * 1024)); + + // Use a tolerance or exact string match if we know the implementation + // 1.5GB = 1.5 * 1024 * 1024 * 1024 = 1610612736 + Assert.AreEqual("1.5GB", AppUtils.FileSizeToString(1610612736)); + Assert.AreEqual("500 Bytes", AppUtils.FileSizeToString(500)); + } + + [TestMethod] + public void StringToFileSize_ConvertsCorrectly() + { + Assert.AreEqual(1024, AppUtils.StringToFileSize("1KB")); + Assert.AreEqual(1024 * 1024, AppUtils.StringToFileSize("1MB")); + Assert.AreEqual(500, AppUtils.StringToFileSize("500B")); + Assert.AreEqual(0, AppUtils.StringToFileSize("Invalid")); + } + + [TestMethod] + public void ToLlmPromptTemplate_ConvertsCorrectly() + { + var template = new PromptTemplate + { + System = "System prompt", + User = "User prompt", + Assistant = "Assistant prompt", + Stop = new[] { "stop1", "stop2" } + }; + + var llmTemplate = AppUtils.ToLlmPromptTemplate(template); + + Assert.AreEqual(template.System, llmTemplate.System); + Assert.AreEqual(template.User, llmTemplate.User); + Assert.AreEqual(template.Assistant, llmTemplate.Assistant); + CollectionAssert.AreEqual(template.Stop, llmTemplate.Stop); + } + + [TestMethod] + public void ToPerc_FormatsCorrectly() + { + Assert.AreEqual("50.5%", AppUtils.ToPerc(50.5f)); + Assert.AreEqual("100.0%", AppUtils.ToPerc(100.0f)); + Assert.AreEqual("0.0%", AppUtils.ToPerc(0.0f)); + } + + [TestMethod] + public void GetHardwareAcceleratorsString_ReturnsCommaSeparatedString() + { + var accelerators = new List + { + HardwareAccelerator.CPU, + HardwareAccelerator.GPU + }; + + var result = AppUtils.GetHardwareAcceleratorsString(accelerators); + + // Note: The order depends on the input list order and implementation. + // GetHardwareAcceleratorString returns "CPU" for CPU and "GPU" for GPU/DML. + Assert.IsTrue(result.Contains("CPU")); + Assert.IsTrue(result.Contains("GPU")); + Assert.IsTrue(result.Contains(", ")); + } + + [TestMethod] + public void GetModelTypeStringFromHardwareAccelerators_ReturnsCorrectType() + { + var accelerators = new List { HardwareAccelerator.CPU }; + Assert.AreEqual("ONNX", AppUtils.GetModelTypeStringFromHardwareAccelerators(accelerators)); + + accelerators = new List { HardwareAccelerator.GPU }; + Assert.AreEqual("ONNX", AppUtils.GetModelTypeStringFromHardwareAccelerators(accelerators)); + + accelerators = new List { HardwareAccelerator.NPU }; + Assert.AreEqual("ONNX", AppUtils.GetModelTypeStringFromHardwareAccelerators(accelerators)); + + accelerators = new List(); + Assert.AreEqual(string.Empty, AppUtils.GetModelTypeStringFromHardwareAccelerators(accelerators)); + } + + [TestMethod] + public void GetHardwareAcceleratorString_ReturnsCorrectString() + { + Assert.AreEqual("GPU", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.GPU)); + Assert.AreEqual("GPU", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.DML)); + Assert.AreEqual("NPU", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.NPU)); + Assert.AreEqual("NPU", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.QNN)); + Assert.AreEqual("Windows AI API", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.ACI)); + Assert.AreEqual("CPU", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.CPU)); + } + + [TestMethod] + public void GetHardwareAcceleratorDescription_ReturnsCorrectDescription() + { + Assert.AreEqual("This model will run on CPU", AppUtils.GetHardwareAcceleratorDescription(HardwareAccelerator.CPU)); + Assert.AreEqual("This model will run on supported GPUs with DirectML", AppUtils.GetHardwareAcceleratorDescription(HardwareAccelerator.GPU)); + Assert.AreEqual("This model will run on NPUs", AppUtils.GetHardwareAcceleratorDescription(HardwareAccelerator.NPU)); + Assert.AreEqual("The model will run locally via Ollama", AppUtils.GetHardwareAcceleratorDescription(HardwareAccelerator.OLLAMA)); + } + + [TestMethod] + public void GetModelSourceOriginFromUrl_ReturnsCorrectOrigin() + { + Assert.AreEqual("This model was downloaded from Hugging Face", AppUtils.GetModelSourceOriginFromUrl("https://huggingface.co/model")); + Assert.AreEqual("This model was downloaded from GitHub", AppUtils.GetModelSourceOriginFromUrl("https://github.com/model")); + Assert.AreEqual("This model was added by you", AppUtils.GetModelSourceOriginFromUrl("local/path")); + Assert.AreEqual(string.Empty, AppUtils.GetModelSourceOriginFromUrl("https://example.com")); + } + + [TestMethod] + public void GetLicenseTitleFromString_ReturnsCorrectTitle() + { + Assert.AreEqual("MIT", AppUtils.GetLicenseTitleFromString("mit")); + Assert.AreEqual("Unknown", AppUtils.GetLicenseTitleFromString("unknown-license")); + } + + [TestMethod] + public void GetLicenseShortNameFromString_ReturnsCorrectName() + { + Assert.AreEqual("mit", AppUtils.GetLicenseShortNameFromString("mit")); + Assert.AreEqual("Unknown", AppUtils.GetLicenseShortNameFromString(null)); + Assert.AreEqual("Unknown", AppUtils.GetLicenseShortNameFromString(string.Empty)); + } + + [TestMethod] + public void GetLicenseUrlFromModel_ReturnsCorrectUrl() + { + var model = new ModelDetails + { + License = "mit", + Url = "https://model.url" + }; + + var uri = AppUtils.GetLicenseUrlFromModel(model); + Assert.IsTrue(uri.ToString().Contains("mit")); + + model.License = "unknown"; + uri = AppUtils.GetLicenseUrlFromModel(model); + Assert.AreEqual("https://model.url/", uri.ToString()); + } +} \ No newline at end of file diff --git a/AIDevGallery.UnitTests/app.manifest b/AIDevGallery.Tests/app.manifest similarity index 92% rename from AIDevGallery.UnitTests/app.manifest rename to AIDevGallery.Tests/app.manifest index ff8dbff5..79b30348 100644 --- a/AIDevGallery.UnitTests/app.manifest +++ b/AIDevGallery.Tests/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/AIDevGallery.sln b/AIDevGallery.sln index 7e24d992..f30264ae 100644 --- a/AIDevGallery.sln +++ b/AIDevGallery.sln @@ -18,7 +18,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AIDevGallery.SourceGenerato EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AIDevGallery.Utils", "AIDevGallery.Utils\AIDevGallery.Utils.csproj", "{987BD688-3416-4741-89D8-CFBEED197F7B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AIDevGallery.UnitTests", "AIDevGallery.UnitTests\AIDevGallery.UnitTests.csproj", "{A6D0DD34-2584-4634-8D4B-5D3D227B5679}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AIDevGallery.Tests", "AIDevGallery.Tests\AIDevGallery.Tests.csproj", "{A6D0DD34-2584-4634-8D4B-5D3D227B5679}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/AIDevGallery/AIDevGallery.csproj b/AIDevGallery/AIDevGallery.csproj index e19d9668..ccf7ada1 100644 --- a/AIDevGallery/AIDevGallery.csproj +++ b/AIDevGallery/AIDevGallery.csproj @@ -290,7 +290,7 @@ - <_Parameter1>AIDevGallery.UnitTests + <_Parameter1>AIDevGallery.Tests diff --git a/Directory.Packages.props b/Directory.Packages.props index 731db2a7..8f3fcfac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,5 +55,7 @@ + + \ No newline at end of file From 2660efe47c637e0f7078b1d026de89f44b550030 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 17:40:28 +0800 Subject: [PATCH 032/115] update --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b23dad9..a303430d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,8 @@ jobs: uses: microsoft/setup-msbuild@v2 - name: Restore dependencies run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} + - name: Build AIDevGallery.Utils + run: dotnet build AIDevGallery.Utils --no-restore /p:Configuration=${{ matrix.dotnet-configuration }} - name: Build Test Project run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Run Unit Tests @@ -62,8 +64,6 @@ jobs: path: TestResults build: - needs: pr-unit-test - if: ${{ !cancelled() && (needs.pr-unit-test.result == 'success' || needs.pr-unit-test.result == 'skipped') }} strategy: fail-fast: false matrix: From 08bed0a66b906d39949c2a1192b54884fb36e3ee Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 17:47:11 +0800 Subject: [PATCH 033/115] update --- AIDevGallery.Tests/TestInfra/FlaUITestBase.cs | 2 +- AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs index 30f06420..286307b4 100644 --- a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs +++ b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs @@ -270,7 +270,7 @@ public virtual void TestInitialize() break; } } - catch (Exception ex) + catch (Exception) { // Window not ready yet, continue waiting var elapsed = DateTime.Now - startTime; diff --git a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs index 7fcf31a5..f9bd059e 100644 --- a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs +++ b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs @@ -251,7 +251,7 @@ public virtual void TestInitialize() break; } } - catch (Exception ex) + catch (Exception) { var elapsed = DateTime.Now - startTime; if (elapsed.TotalSeconds % 10 < 1) From 349f4e4d9082ac4dfc4f31837e4d7ce39ae645c9 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 17:56:27 +0800 Subject: [PATCH 034/115] format --- .../TestInfra/PerformanceCollector.cs | 1 + .../UITests/BasicInteractionTests.cs | 3 ++ AIDevGallery.Tests/UITests/MainWindowTests.cs | 36 +++++++++---------- .../UITests/NavigationViewTests.cs | 1 - 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs index 30d591e1..9d85317b 100644 --- a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs +++ b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs @@ -79,6 +79,7 @@ public class Measurement /// public static class PerformanceCollector { + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly List _measurements = new(); private static readonly object _lock = new(); diff --git a/AIDevGallery.Tests/UITests/BasicInteractionTests.cs b/AIDevGallery.Tests/UITests/BasicInteractionTests.cs index cf25e931..d691d9a4 100644 --- a/AIDevGallery.Tests/UITests/BasicInteractionTests.cs +++ b/AIDevGallery.Tests/UITests/BasicInteractionTests.cs @@ -5,6 +5,7 @@ using FlaUI.Core.Input; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace AIDevGallery.Tests.UITests; @@ -14,6 +15,7 @@ namespace AIDevGallery.Tests.UITests; /// These tests show how to interact with the AIDevGallery UI. /// [TestClass] +[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Test method naming convention")] public class BasicInteractionTests : FlaUITestBase { [TestMethod] @@ -52,6 +54,7 @@ public void Sample_FindAllClickableElements() { automationId = "(not supported)"; } + Console.WriteLine($" - {name} [ID: {automationId}]"); } diff --git a/AIDevGallery.Tests/UITests/MainWindowTests.cs b/AIDevGallery.Tests/UITests/MainWindowTests.cs index 52e0a58c..f63d06e4 100644 --- a/AIDevGallery.Tests/UITests/MainWindowTests.cs +++ b/AIDevGallery.Tests/UITests/MainWindowTests.cs @@ -242,13 +242,13 @@ public void SearchBox_DisplaysResults_WhenQueryEntered() { Assert.IsNotNull(MainWindow, "Main window should be initialized"); -// pane 'Desktop 1' -// - windows 'AI Dev Gallery Dev' -// - pane '' -// - pane '' -// - title bar 'AI Dev Gallery' (AutomationId="titleBar") -// - group '' (AutomationId="SearchBox") -// - edit 'Name Search samples, models & APIs..'(AutomationId="TextBox") + // pane 'Desktop 1' + // - windows 'AI Dev Gallery Dev' + // - pane '' + // - pane '' + // - title bar 'AI Dev Gallery' (AutomationId="titleBar") + // - group '' (AutomationId="SearchBox") + // - edit 'Name Search samples, models & APIs..'(AutomationId="TextBox") var searchBoxGroupResult = Retry.WhileNull( () => MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("SearchBox")), timeout: TimeSpan.FromSeconds(10)); @@ -263,17 +263,17 @@ public void SearchBox_DisplaysResults_WhenQueryEntered() searchBox.AsTextBox().Text = "Phi"; Console.WriteLine("Search query 'Phi' entered"); -// pane 'Desktop 1' -// - windows 'AI Dev Gallery Dev' -// - pane 'PopupHost' -// - pane '' -// - title bar 'AI Dev Gallery' (AutomationId="titleBar") -// - group 'SearchBox' (AutomationId="SearchBox") -// - window 'Popup' (AutomationId="SuggestionsPopup") -// - list '' (AutomationId="SuggestionsList") // length needs to be > 0 -// - list item 'Phi 3 Medium' -// - list item ... -// - ... + // pane 'Desktop 1' + // - windows 'AI Dev Gallery Dev' + // - pane 'PopupHost' + // - pane '' + // - title bar 'AI Dev Gallery' (AutomationId="titleBar") + // - group 'SearchBox' (AutomationId="SearchBox") + // - window 'Popup' (AutomationId="SuggestionsPopup") + // - list '' (AutomationId="SuggestionsList") // length needs to be > 0 + // - list item 'Phi 3 Medium' + // - list item ... + // - ... var suggestionsPopupResult = Retry.WhileNull( () => searchBoxGroup.FindFirstDescendant(cf => cf.ByAutomationId("SuggestionsPopup")), timeout: TimeSpan.FromSeconds(5)); diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index 4dc0d227..a40eb641 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using AIDevGallery.Tests.TestInfra; -using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; From 6d09f55d7872af7832a85dc5f855f81c95b42efc Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 18:06:28 +0800 Subject: [PATCH 035/115] update --- AIDevGallery.Tests/TestInfra/FlaUITestBase.cs | 14 +++++++++----- AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs | 12 +++++++----- .../TestInfra/PerformanceCollector.cs | 5 ++--- AIDevGallery.Tests/UITests/MainWindowTests.cs | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs index 286307b4..9a7f721f 100644 --- a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs +++ b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs @@ -27,7 +27,7 @@ public abstract class FlaUITestBase /// /// Gets the path to the AIDevGallery executable. /// - /// + /// The full path to the AIDevGallery executable. protected virtual string GetApplicationPath() { // Try to find the built application @@ -224,7 +224,8 @@ public virtual void TestInitialize() throw new InvalidOperationException( $"Could not launch MSIX package: {packageFamilyName}{Environment.NewLine}" + $"Error: {ex.Message}{Environment.NewLine}" + - $"Try launching the app manually from Start Menu to verify it works.", ex); + $"Try launching the app manually from Start Menu to verify it works.", + ex); } } else @@ -246,7 +247,8 @@ public virtual void TestInitialize() throw new InvalidOperationException( $"Could not launch unpackaged application. WinUI3 requires MSIX deployment for testing.{Environment.NewLine}" + $"Please run: msbuild AIDevGallery\\AIDevGallery.csproj /t:Deploy /p:Configuration=Debug /p:Platform=x64{Environment.NewLine}" + - $"See MSIX_DEPLOYMENT_REQUIRED.md for details.", ex); + $"See MSIX_DEPLOYMENT_REQUIRED.md for details.", + ex); } } @@ -274,7 +276,9 @@ public virtual void TestInitialize() { // Window not ready yet, continue waiting var elapsed = DateTime.Now - startTime; - if (elapsed.TotalSeconds % 10 < 1) // Log every ~10 seconds + + // Log every ~10 seconds + if (elapsed.TotalSeconds % 10 < 1) { Console.WriteLine($"Still waiting for window... ({elapsed.TotalSeconds:F0}s elapsed)"); } @@ -403,7 +407,7 @@ private static void CloseExistingApplicationInstances() /// /// Waits for an element to appear with the specified automation ID. /// - /// + /// The automation element if found, null otherwise. protected AutomationElement? WaitForElement(string automationId, TimeSpan timeout) { if (MainWindow == null) diff --git a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs index f9bd059e..a89edacc 100644 --- a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs +++ b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs @@ -26,7 +26,7 @@ public abstract class NativeUIA3TestBase /// /// Gets the path to the AIDevGallery executable. /// - /// + /// The full path to the AIDevGallery executable. protected virtual string GetApplicationPath() { var solutionDir = FindSolutionDirectory(); @@ -204,7 +204,8 @@ public virtual void TestInitialize() throw new InvalidOperationException( $"Could not launch MSIX package: {packageFamilyName}{Environment.NewLine}" + $"Error: {ex.Message}{Environment.NewLine}" + - $"Try launching the app manually from Start Menu to verify it works.", ex); + $"Try launching the app manually from Start Menu to verify it works.", + ex); } } else @@ -229,7 +230,8 @@ public virtual void TestInitialize() throw new InvalidOperationException( $"Could not launch unpackaged application. WinUI3 requires MSIX deployment for testing.{Environment.NewLine}" + $"Please run: msbuild AIDevGallery\\AIDevGallery.csproj /t:Deploy /p:Configuration=Debug /p:Platform=x64{Environment.NewLine}" + - $"See MSIX_DEPLOYMENT_REQUIRED.md for details.", ex); + $"See MSIX_DEPLOYMENT_REQUIRED.md for details.", + ex); } } @@ -418,7 +420,7 @@ private static void CloseExistingApplicationInstances() /// /// Waits for an element to appear with the specified automation ID. /// - /// + /// The UI automation element if found, null otherwise. protected IUIAutomationElement? WaitForElement(string automationId, TimeSpan timeout) { if (MainWindow == null || Automation == null) @@ -452,7 +454,7 @@ private static void CloseExistingApplicationInstances() /// /// Gets all descendant elements. /// - /// + /// Array of all descendant UI automation elements. protected IUIAutomationElement[] GetAllDescendants() { if (MainWindow == null || Automation == null) diff --git a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs index 9d85317b..4fc3ea0b 100644 --- a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs +++ b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs @@ -131,8 +131,7 @@ public static string Save(string? outputDirectory = null) Measurements = measurementsSnapshot }; - var options = new JsonSerializerOptions { WriteIndented = true }; - var json = JsonSerializer.Serialize(report, options); + var json = JsonSerializer.Serialize(report, JsonOptions); // Allow overriding output directory via environment variable (useful for CI) string? envOutputDir = Environment.GetEnvironmentVariable("PERFORMANCE_OUTPUT_PATH"); @@ -208,7 +207,7 @@ public static bool TrackMemoryUsage(int processId, string metricName, Dictionary /// True if successful, false if measurement failed. public static bool TrackCurrentProcessMemory(string metricName, Dictionary? tags = null, string category = "Memory") { - return TrackMemoryUsage(Process.GetCurrentProcess().Id, metricName, tags, category); + return TrackMemoryUsage(Environment.ProcessId, metricName, tags, category); } /// diff --git a/AIDevGallery.Tests/UITests/MainWindowTests.cs b/AIDevGallery.Tests/UITests/MainWindowTests.cs index f63d06e4..f6f6ef95 100644 --- a/AIDevGallery.Tests/UITests/MainWindowTests.cs +++ b/AIDevGallery.Tests/UITests/MainWindowTests.cs @@ -248,7 +248,7 @@ public void SearchBox_DisplaysResults_WhenQueryEntered() // - pane '' // - title bar 'AI Dev Gallery' (AutomationId="titleBar") // - group '' (AutomationId="SearchBox") - // - edit 'Name Search samples, models & APIs..'(AutomationId="TextBox") + // - edit 'Name Search samples, models & APIs..'(AutomationId="TextBox") var searchBoxGroupResult = Retry.WhileNull( () => MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("SearchBox")), timeout: TimeSpan.FromSeconds(10)); From efeda2bb9d18fa4db08eb86ecf5e52e591d663c4 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 18:18:21 +0800 Subject: [PATCH 036/115] format --- AIDevGallery.Tests/UITests/MainWindowTests.cs | 18 +++++++------- .../UITests/NativeUIA3SmokeTests.cs | 13 +++++----- .../UITests/NavigationViewTests.cs | 2 +- AIDevGallery.Tests/UITests/SmokeTests.cs | 10 ++++---- .../UnitTests/Utils/AppUtilsTests.cs | 24 +++++++++---------- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/AIDevGallery.Tests/UITests/MainWindowTests.cs b/AIDevGallery.Tests/UITests/MainWindowTests.cs index f6f6ef95..9a2b5f65 100644 --- a/AIDevGallery.Tests/UITests/MainWindowTests.cs +++ b/AIDevGallery.Tests/UITests/MainWindowTests.cs @@ -19,7 +19,7 @@ public class MainWindowTests : FlaUITestBase [TestMethod] [TestCategory("UI")] [Description("Verifies that the main window launches successfully")] - public void MainWindow_Launches_Successfully() + public void MainWindowLaunchesSuccessfully() { // Assert Assert.IsNotNull(MainWindow, "Main window should be initialized"); @@ -34,7 +34,7 @@ public void MainWindow_Launches_Successfully() [TestMethod] [TestCategory("UI")] [Description("Verifies that the main window is visible and not minimized")] - public void MainWindow_IsVisible() + public void MainWindowIsVisible() { // Assert Assert.IsNotNull(MainWindow, "Main window should be initialized"); @@ -48,7 +48,7 @@ public void MainWindow_IsVisible() [TestMethod] [TestCategory("UI")] [Description("Verifies that the main window can be resized")] - public void MainWindow_CanBeResized() + public void MainWindowCanBeResized() { // Arrange Assert.IsNotNull(MainWindow, "Main window should be initialized"); @@ -114,7 +114,7 @@ public void MainWindow_CanBeResized() [TestMethod] [TestCategory("UI")] [Description("Verifies that the main window contains UI elements")] - public void MainWindow_ContainsUIElements() + public void MainWindowContainsUIElements() { // Arrange Assert.IsNotNull(MainWindow, "Main window should be initialized"); @@ -143,7 +143,7 @@ public void MainWindow_ContainsUIElements() [TestMethod] [TestCategory("UI")] [Description("Verifies that buttons can be found in the main window")] - public void MainWindow_ContainsButtons() + public void MainWindowContainsButtons() { // Arrange Assert.IsNotNull(MainWindow, "Main window should be initialized"); @@ -179,7 +179,7 @@ public void MainWindow_ContainsButtons() [TestMethod] [TestCategory("UI")] [Description("Verifies that the main window can be closed")] - public void MainWindow_CanBeClosed() + public void MainWindowCanBeClosed() { // Arrange Assert.IsNotNull(MainWindow, "Main window should be initialized"); @@ -198,14 +198,14 @@ public void MainWindow_CanBeClosed() catch { // If accessing IsAvailable throws, the window is definitely closed - Assert.IsTrue(true, "Window was closed successfully"); + // Window was closed successfully } } [TestMethod] [TestCategory("UI")] [Description("Verifies that text elements can be found in the main window")] - public void MainWindow_ContainsTextElements() + public void MainWindowContainsTextElements() { // Arrange Assert.IsNotNull(MainWindow, "Main window should be initialized"); @@ -238,7 +238,7 @@ public void MainWindow_ContainsTextElements() [TestMethod] [TestCategory("UI")] [Description("Verifies that the search box accepts input and displays search results")] - public void SearchBox_DisplaysResults_WhenQueryEntered() + public void SearchBoxDisplaysResultsWhenQueryEntered() { Assert.IsNotNull(MainWindow, "Main window should be initialized"); diff --git a/AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs b/AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs index 0d7d660a..ab0fca9f 100644 --- a/AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs +++ b/AIDevGallery.Tests/UITests/NativeUIA3SmokeTests.cs @@ -23,7 +23,7 @@ public class NativeUIA3SmokeTests : NativeUIA3TestBase [TestCategory("Smoke")] [TestCategory("NativeUIA3")] [Description("Verifies that the application can be found and launched using native UIA3")] - public void NativeUIA3_ApplicationLaunches() + public void NativeUIA3ApplicationLaunches() { // The TestInitialize already launched the app and got the main window Assert.IsNotNull(AppProcess, "Application process should be launched"); @@ -51,7 +51,7 @@ public void NativeUIA3_ApplicationLaunches() [TestCategory("Smoke")] [TestCategory("NativeUIA3")] [Description("Verifies that the main window has basic properties using native UIA3")] - public void NativeUIA3_MainWindowHasBasicProperties() + public void NativeUIA3MainWindowHasBasicProperties() { Assert.IsNotNull(MainWindow, "Main window should exist"); @@ -91,7 +91,7 @@ public void NativeUIA3_MainWindowHasBasicProperties() [TestCategory("Smoke")] [TestCategory("NativeUIA3")] [Description("Verifies that UI elements can be queried using native UIA3")] - public void NativeUIA3_CanQueryUIElements() + public void NativeUIA3CanQueryUIElements() { Assert.IsNotNull(MainWindow, "Main window should exist"); @@ -139,7 +139,7 @@ public void NativeUIA3_CanQueryUIElements() [TestCategory("Sample")] [TestCategory("NativeUIA3")] [Description("Logs details for elements that expose AutomationId values using native UIA3")] - public void NativeUIA3_LogAutomationIds() + public void NativeUIA3LogAutomationIds() { Assert.IsNotNull(MainWindow, "Main window should be initialized"); Assert.IsNotNull(Automation, "Automation should be initialized"); @@ -341,7 +341,7 @@ static string DescribeBounds(IUIAutomationElement element) } } - Assert.IsTrue(true, "This sample is intended only for logging AutomationId information"); + // This sample is intended only for logging AutomationId information } [TestMethod] @@ -349,7 +349,7 @@ static string DescribeBounds(IUIAutomationElement element) [TestCategory("Sample")] [TestCategory("NativeUIA3")] [Description("Tests finding specific elements by AutomationId using native UIA3")] - public void NativeUIA3_FindElementByAutomationId() + public void NativeUIA3FindElementByAutomationId() { Assert.IsNotNull(MainWindow, "Main window should exist"); Assert.IsNotNull(Automation, "Automation should be initialized"); @@ -401,6 +401,5 @@ public void NativeUIA3_FindElementByAutomationId() } // This test is informational, so always pass - Assert.IsTrue(true, "Element search test completed"); } } \ No newline at end of file diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index a40eb641..f815d125 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -22,7 +22,7 @@ public class NavigationViewTests : FlaUITestBase [TestCategory("UI")] [TestCategory("Navigation")] [Description("Test clicking all NavigationView items")] - public void NavigationView_ClickAllLeftMenuHostItems() + public void NavigationViewClickAllLeftMenuHostItems() { // Arrange Assert.IsNotNull(MainWindow, "Main window should be initialized"); diff --git a/AIDevGallery.Tests/UITests/SmokeTests.cs b/AIDevGallery.Tests/UITests/SmokeTests.cs index 1fabc092..4a33a39d 100644 --- a/AIDevGallery.Tests/UITests/SmokeTests.cs +++ b/AIDevGallery.Tests/UITests/SmokeTests.cs @@ -20,7 +20,7 @@ public class SmokeTests : FlaUITestBase [TestCategory("UI")] [TestCategory("Smoke")] [Description("Verifies that the application can be found and launched")] - public void Smoke_ApplicationLaunches() + public void SmokeApplicationLaunches() { // The TestInitialize already launched the app and got the main window // This test just verifies that setup worked @@ -39,7 +39,7 @@ public void Smoke_ApplicationLaunches() [TestCategory("UI")] [TestCategory("Smoke")] [Description("Verifies that the main window has basic properties")] - public void Smoke_MainWindowHasBasicProperties() + public void SmokeMainWindowHasBasicProperties() { Assert.IsNotNull(MainWindow, "Main window should exist"); @@ -61,7 +61,7 @@ public void Smoke_MainWindowHasBasicProperties() [TestCategory("UI")] [TestCategory("Smoke")] [Description("Verifies that UI elements can be queried")] - public void Smoke_CanQueryUIElements() + public void SmokeCanQueryUIElements() { Assert.IsNotNull(MainWindow, "Main window should exist"); @@ -87,7 +87,7 @@ public void Smoke_CanQueryUIElements() [TestCategory("UI")] [TestCategory("Sample")] [Description("Logs details for elements that expose AutomationId values")] - public void Sample_LogAutomationIds() + public void SampleLogAutomationIds() { var window = MainWindow ?? throw new InvalidOperationException("Main window should be initialized"); @@ -209,6 +209,6 @@ static string DescribeBounds(FlaUI.Core.AutomationElements.AutomationElement ele Console.WriteLine($" ... and {elementsWithoutId.Count - 50} more without AutomationId"); } - Assert.IsTrue(true, "This sample is intended only for logging AutomationId information"); + // This sample is intended only for logging AutomationId information } } \ No newline at end of file diff --git a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs index c0f46bb5..481b660e 100644 --- a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs +++ b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs @@ -12,7 +12,7 @@ namespace AIDevGallery.Tests.Unit; public class AppUtilsTests { [TestMethod] - public void FileSizeToString_ConvertsCorrectly() + public void FileSizeToStringConvertsCorrectly() { Assert.AreEqual("1.0KB", AppUtils.FileSizeToString(1024)); Assert.AreEqual("1.0MB", AppUtils.FileSizeToString(1024 * 1024)); @@ -24,7 +24,7 @@ public void FileSizeToString_ConvertsCorrectly() } [TestMethod] - public void StringToFileSize_ConvertsCorrectly() + public void StringToFileSizeConvertsCorrectly() { Assert.AreEqual(1024, AppUtils.StringToFileSize("1KB")); Assert.AreEqual(1024 * 1024, AppUtils.StringToFileSize("1MB")); @@ -33,7 +33,7 @@ public void StringToFileSize_ConvertsCorrectly() } [TestMethod] - public void ToLlmPromptTemplate_ConvertsCorrectly() + public void ToLlmPromptTemplateConvertsCorrectly() { var template = new PromptTemplate { @@ -52,7 +52,7 @@ public void ToLlmPromptTemplate_ConvertsCorrectly() } [TestMethod] - public void ToPerc_FormatsCorrectly() + public void ToPercFormatsCorrectly() { Assert.AreEqual("50.5%", AppUtils.ToPerc(50.5f)); Assert.AreEqual("100.0%", AppUtils.ToPerc(100.0f)); @@ -60,7 +60,7 @@ public void ToPerc_FormatsCorrectly() } [TestMethod] - public void GetHardwareAcceleratorsString_ReturnsCommaSeparatedString() + public void GetHardwareAcceleratorsStringReturnsCommaSeparatedString() { var accelerators = new List { @@ -78,7 +78,7 @@ public void GetHardwareAcceleratorsString_ReturnsCommaSeparatedString() } [TestMethod] - public void GetModelTypeStringFromHardwareAccelerators_ReturnsCorrectType() + public void GetModelTypeStringFromHardwareAcceleratorsReturnsCorrectType() { var accelerators = new List { HardwareAccelerator.CPU }; Assert.AreEqual("ONNX", AppUtils.GetModelTypeStringFromHardwareAccelerators(accelerators)); @@ -94,7 +94,7 @@ public void GetModelTypeStringFromHardwareAccelerators_ReturnsCorrectType() } [TestMethod] - public void GetHardwareAcceleratorString_ReturnsCorrectString() + public void GetHardwareAcceleratorStringReturnsCorrectString() { Assert.AreEqual("GPU", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.GPU)); Assert.AreEqual("GPU", AppUtils.GetHardwareAcceleratorString(HardwareAccelerator.DML)); @@ -105,7 +105,7 @@ public void GetHardwareAcceleratorString_ReturnsCorrectString() } [TestMethod] - public void GetHardwareAcceleratorDescription_ReturnsCorrectDescription() + public void GetHardwareAcceleratorDescriptionReturnsCorrectDescription() { Assert.AreEqual("This model will run on CPU", AppUtils.GetHardwareAcceleratorDescription(HardwareAccelerator.CPU)); Assert.AreEqual("This model will run on supported GPUs with DirectML", AppUtils.GetHardwareAcceleratorDescription(HardwareAccelerator.GPU)); @@ -114,7 +114,7 @@ public void GetHardwareAcceleratorDescription_ReturnsCorrectDescription() } [TestMethod] - public void GetModelSourceOriginFromUrl_ReturnsCorrectOrigin() + public void GetModelSourceOriginFromUrlReturnsCorrectOrigin() { Assert.AreEqual("This model was downloaded from Hugging Face", AppUtils.GetModelSourceOriginFromUrl("https://huggingface.co/model")); Assert.AreEqual("This model was downloaded from GitHub", AppUtils.GetModelSourceOriginFromUrl("https://github.com/model")); @@ -123,14 +123,14 @@ public void GetModelSourceOriginFromUrl_ReturnsCorrectOrigin() } [TestMethod] - public void GetLicenseTitleFromString_ReturnsCorrectTitle() + public void GetLicenseTitleFromStringReturnsCorrectTitle() { Assert.AreEqual("MIT", AppUtils.GetLicenseTitleFromString("mit")); Assert.AreEqual("Unknown", AppUtils.GetLicenseTitleFromString("unknown-license")); } [TestMethod] - public void GetLicenseShortNameFromString_ReturnsCorrectName() + public void GetLicenseShortNameFromStringReturnsCorrectName() { Assert.AreEqual("mit", AppUtils.GetLicenseShortNameFromString("mit")); Assert.AreEqual("Unknown", AppUtils.GetLicenseShortNameFromString(null)); @@ -138,7 +138,7 @@ public void GetLicenseShortNameFromString_ReturnsCorrectName() } [TestMethod] - public void GetLicenseUrlFromModel_ReturnsCorrectUrl() + public void GetLicenseUrlFromModelReturnsCorrectUrl() { var model = new ModelDetails { From f2a265f54c49264c4ddd81a5ec4cb02eeadf73f0 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 18:27:01 +0800 Subject: [PATCH 037/115] format --- .github/workflows/build.yml | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a303430d..0aac36af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: - os: windows-2025 dotnet-arch: 'x64' dotnet-configuration: 'Release' - name: PR Unit Tests - win-${{ matrix.dotnet-arch }} + name: Unit Tests - win-${{ matrix.dotnet-arch }} runs-on: ${{ matrix.os }} permissions: contents: read @@ -43,14 +43,18 @@ jobs: run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Run Unit Tests run: | - $testAssembly = ".\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.exe" - $testAdapterPath = "$HOME\.nuget\packages\mstest.testadapter\3.9.2\buildTransitive\net9.0" - # Create TestResults directory if it doesn't exist New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null # Run only unit tests (exclude IntegrationTests and UITests) - vstest.console.exe $testAssembly /TestCaseFilter:"FullyQualifiedName~UnitTests" /TestAdapterPath:$testAdapterPath /logger:"trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" + dotnet test AIDevGallery.Tests ` + --no-build ` + --configuration ${{ matrix.dotnet-configuration }} ` + --runtime win-${{ matrix.dotnet-arch }} ` + --framework net9.0-windows10.0.26100.0 ` + --filter "FullyQualifiedName~UnitTests" ` + --logger "trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" ` + --verbosity normal if ($LASTEXITCODE -ne 0) { Write-Error "Unit tests failed with exit code $LASTEXITCODE" @@ -131,7 +135,7 @@ jobs: - os: windows-11-arm dotnet-arch: 'arm64' dotnet-configuration: 'Release' - name: Test - win-${{ matrix.dotnet-arch }} + name: Regression Tests - win-${{ matrix.dotnet-arch }} runs-on: ${{ matrix.os }} permissions: contents: read @@ -177,7 +181,18 @@ jobs: run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Run All Tests if: github.event_name == 'workflow_dispatch' - run: vstest.console.exe .\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.exe /TestAdapterPath:"$HOME\.nuget\mstest.testadapter\3.9.2\buildTransitive\net9.0" /logger:"trx;LogFileName=${{ github.workspace }}\TestResults\VsTestResults.trx" + run: | + # Create TestResults directory if it doesn't exist + New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null + + # Run all tests using dotnet test + dotnet test AIDevGallery.Tests ` + --no-build ` + --configuration ${{ matrix.dotnet-configuration }} ` + --runtime win-${{ matrix.dotnet-arch }} ` + --framework net9.0-windows10.0.26100.0 ` + --logger "trx;LogFileName=${{ github.workspace }}\TestResults\VsTestResults.trx" ` + --verbosity normal - name: Publish Test Builds If Failed if: failure() uses: actions/upload-artifact@v4 From 6c1e5e0d6ba6e6f6b1fe351e9b244a2360ee7392 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 18:34:51 +0800 Subject: [PATCH 038/115] fail intention --- AIDevGallery.Tests/UITests/NavigationViewTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index f815d125..bb2e5139 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -31,7 +31,7 @@ public void NavigationViewClickAllLeftMenuHostItems() Thread.Sleep(1000); // Act - Find the MenuItemsHost to get only top-level navigation items - var menuItemsHost = MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHost")); + var menuItemsHost = MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHosty")); Assert.IsNotNull(menuItemsHost, "MenuItemsHost should be found"); From 8ce16237b50dd96354938329bd94d69a9779a5ff Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 18:35:05 +0800 Subject: [PATCH 039/115] revert --- AIDevGallery.Tests/UITests/NavigationViewTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index bb2e5139..f815d125 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -31,7 +31,7 @@ public void NavigationViewClickAllLeftMenuHostItems() Thread.Sleep(1000); // Act - Find the MenuItemsHost to get only top-level navigation items - var menuItemsHost = MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHosty")); + var menuItemsHost = MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHost")); Assert.IsNotNull(menuItemsHost, "MenuItemsHost should be found"); From 19a764c7e2592d630ace11c93fd7f21f37400741 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 18:45:10 +0800 Subject: [PATCH 040/115] fail intention --- AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs index 481b660e..3f7364ac 100644 --- a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs +++ b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs @@ -14,7 +14,7 @@ public class AppUtilsTests [TestMethod] public void FileSizeToStringConvertsCorrectly() { - Assert.AreEqual("1.0KB", AppUtils.FileSizeToString(1024)); + Assert.AreEqual("2.0KB", AppUtils.FileSizeToString(1024)); Assert.AreEqual("1.0MB", AppUtils.FileSizeToString(1024 * 1024)); // Use a tolerance or exact string match if we know the implementation From e95f57ee75a9123ae8caf84f9100c492b55d55a7 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 18:59:12 +0800 Subject: [PATCH 041/115] fix --- AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs index 3f7364ac..1f64d02a 100644 --- a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs +++ b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs @@ -6,7 +6,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Collections.Generic; -namespace AIDevGallery.Tests.Unit; +namespace AIDevGallery.Tests.UnitTests; [TestClass] public class AppUtilsTests From e65e61fbd70b89bf5d6d75af7119c8de5dcaef00 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 19:20:14 +0800 Subject: [PATCH 042/115] update --- .github/workflows/build.yml | 6 +----- AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0aac36af..16a5ccb4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,11 +47,7 @@ jobs: New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null # Run only unit tests (exclude IntegrationTests and UITests) - dotnet test AIDevGallery.Tests ` - --no-build ` - --configuration ${{ matrix.dotnet-configuration }} ` - --runtime win-${{ matrix.dotnet-arch }} ` - --framework net9.0-windows10.0.26100.0 ` + dotnet test AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.dll ` --filter "FullyQualifiedName~UnitTests" ` --logger "trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" ` --verbosity normal diff --git a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs index 1f64d02a..7a73a279 100644 --- a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs +++ b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs @@ -14,13 +14,16 @@ public class AppUtilsTests [TestMethod] public void FileSizeToStringConvertsCorrectly() { - Assert.AreEqual("2.0KB", AppUtils.FileSizeToString(1024)); + Assert.AreEqual("1.0KB", AppUtils.FileSizeToString(1024)); Assert.AreEqual("1.0MB", AppUtils.FileSizeToString(1024 * 1024)); // Use a tolerance or exact string match if we know the implementation // 1.5GB = 1.5 * 1024 * 1024 * 1024 = 1610612736 Assert.AreEqual("1.5GB", AppUtils.FileSizeToString(1610612736)); Assert.AreEqual("500 Bytes", AppUtils.FileSizeToString(500)); + + // TODO: Remove this intentional failure after verifying CI error detection + Assert.Fail("Intentionally failing to test CI error detection"); } [TestMethod] From 89c78662846e9a1910453d84215f83c8458c9243 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 17 Dec 2025 19:30:23 +0800 Subject: [PATCH 043/115] finish --- .github/workflows/build.yml | 6 +----- AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs | 3 --- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16a5ccb4..c37e1825 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -182,11 +182,7 @@ jobs: New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null # Run all tests using dotnet test - dotnet test AIDevGallery.Tests ` - --no-build ` - --configuration ${{ matrix.dotnet-configuration }} ` - --runtime win-${{ matrix.dotnet-arch }} ` - --framework net9.0-windows10.0.26100.0 ` + dotnet test AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.dll ` --logger "trx;LogFileName=${{ github.workspace }}\TestResults\VsTestResults.trx" ` --verbosity normal - name: Publish Test Builds If Failed diff --git a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs index 7a73a279..472f6e5c 100644 --- a/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs +++ b/AIDevGallery.Tests/UnitTests/Utils/AppUtilsTests.cs @@ -21,9 +21,6 @@ public void FileSizeToStringConvertsCorrectly() // 1.5GB = 1.5 * 1024 * 1024 * 1024 = 1610612736 Assert.AreEqual("1.5GB", AppUtils.FileSizeToString(1610612736)); Assert.AreEqual("500 Bytes", AppUtils.FileSizeToString(500)); - - // TODO: Remove this intentional failure after verifying CI error detection - Assert.Fail("Intentionally failing to test CI error detection"); } [TestMethod] From 6793e775fa54cf5998e06cdf0efde17dc8d2dc17 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 11:18:14 +0800 Subject: [PATCH 044/115] update --- .github/workflows/build.yml | 104 +++++-------------------- .github/workflows/regression-tests.yml | 101 ++++++++++++++++++++++++ .github/workflows/test-report.yml | 30 ------- 3 files changed, 119 insertions(+), 116 deletions(-) create mode 100644 .github/workflows/regression-tests.yml delete mode 100644 .github/workflows/test-report.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c37e1825..635ff806 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,7 @@ on: jobs: pr-unit-test: if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main') + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -25,6 +26,7 @@ jobs: permissions: contents: read pull-requests: write + checks: write steps: - uses: actions/checkout@v4 with: @@ -35,6 +37,13 @@ jobs: dotnet-version: 9.0.x - name: Add msbuild to PATH uses: microsoft/setup-msbuild@v2 + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- - name: Restore dependencies run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Build AIDevGallery.Utils @@ -43,13 +52,11 @@ jobs: run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Run Unit Tests run: | - # Create TestResults directory if it doesn't exist New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null - - # Run only unit tests (exclude IntegrationTests and UITests) dotnet test AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.dll ` --filter "FullyQualifiedName~UnitTests" ` --logger "trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" ` + --logger "console;verbosity=detailed" ` --verbosity normal if ($LASTEXITCODE -ne 0) { @@ -62,6 +69,14 @@ jobs: with: name: pr-unit-test-results-${{ matrix.dotnet-arch }} path: TestResults + - name: Display Test Report + if: always() + uses: dorny/test-reporter@v2 + with: + name: Unit Test Report - ${{ matrix.dotnet-arch }} + path: '${{ github.workspace }}\TestResults\*.trx' + reporter: dotnet-trx + fail-on-error: false build: strategy: @@ -118,89 +133,6 @@ jobs: with: name: MSIX-${{ matrix.dotnet-arch }} path: ${{ github.workspace }}/AIDevGallery/AppPackages/*_${{ matrix.dotnet-arch }}_Test/AIDevGallery_*_${{ matrix.dotnet-arch }}.msix - test: - needs: pr-unit-test - if: ${{ github.event_name == 'workflow_dispatch' && !cancelled() && (needs.pr-unit-test.result == 'success' || needs.pr-unit-test.result == 'skipped') }} - strategy: - fail-fast: false - matrix: - include: - - os: windows-2025 - dotnet-arch: 'x64' - dotnet-configuration: 'Release' - - os: windows-11-arm - dotnet-arch: 'arm64' - dotnet-configuration: 'Release' - name: Regression Tests - win-${{ matrix.dotnet-arch }} - runs-on: ${{ matrix.os }} - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@v2 - - name: Install NBGV tool - run: dotnet tool install --tool-path . nbgv - - name: Set Version - run: ./nbgv cloud -c - - name: Update Package Manifest Version - run: | - $manifestPath = "${{ github.workspace }}\AIDevGallery\Package.appxmanifest" - [xml]$manifest = get-content $manifestPath - $manifest.Package.Identity.Version = '${{ env.GitBuildVersionSimple }}.0' - $manifest.Save($manifestPath) - - name: Set LAF build props - shell: pwsh - run: | - if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_TOKEN }}")) { - echo "LAF_TOKEN=${{ secrets.LAF_TOKEN }}" >> $env:GITHUB_ENV - } - if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_PUBLISHER_ID }}")) { - echo "LAF_PUBLISHER_ID=${{ secrets.LAF_PUBLISHER_ID }}" >> $env:GITHUB_ENV - } - - name: Restore dependencies - run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:PublishReadyToRun=true - - name: Build - run: | - dotnet build AIDevGallery.Utils --no-restore /p:Configuration=${{ matrix.dotnet-configuration }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" - dotnet build AIDevGallery --no-restore -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" - - name: Setup Dev Tools - uses: ilammy/msvc-dev-cmd@v1 - - name: Build Full Test Suite - if: github.event_name == 'workflow_dispatch' - run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - - name: Run All Tests - if: github.event_name == 'workflow_dispatch' - run: | - # Create TestResults directory if it doesn't exist - New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null - - # Run all tests using dotnet test - dotnet test AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.dll ` - --logger "trx;LogFileName=${{ github.workspace }}\TestResults\VsTestResults.trx" ` - --verbosity normal - - name: Publish Test Builds If Failed - if: failure() - uses: actions/upload-artifact@v4 - with: - name: test-builds-${{ matrix.dotnet-arch }} - path: | - .\AIDevGallery\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} - .\AIDevGallery\obj\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} - .\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} - .\AIDevGallery.Tests\obj\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} - - name: Publish Test Results - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: test-results-${{ matrix.dotnet-arch }} - path: TestResults accessibility-check: needs: build diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml new file mode 100644 index 00000000..6b707f47 --- /dev/null +++ b/.github/workflows/regression-tests.yml @@ -0,0 +1,101 @@ +name: Regression Tests + +on: + workflow_dispatch: + +jobs: + regression-tests: + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: windows-2025 + dotnet-arch: 'x64' + dotnet-configuration: 'Release' + - os: windows-11-arm + dotnet-arch: 'arm64' + dotnet-configuration: 'Release' + name: Regression Tests - win-${{ matrix.dotnet-arch }} + runs-on: ${{ matrix.os }} + permissions: + contents: read + checks: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v2 + - name: Install NBGV tool + run: dotnet tool install --tool-path . nbgv + - name: Set Version + run: ./nbgv cloud -c + - name: Update Package Manifest Version + run: | + $manifestPath = "${{ github.workspace }}\AIDevGallery\Package.appxmanifest" + [xml]$manifest = get-content $manifestPath + $manifest.Package.Identity.Version = '${{ env.GitBuildVersionSimple }}.0' + $manifest.Save($manifestPath) + - name: Set LAF build props + shell: pwsh + run: | + if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_TOKEN }}")) { + echo "LAF_TOKEN=${{ secrets.LAF_TOKEN }}" >> $env:GITHUB_ENV + } + if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_PUBLISHER_ID }}")) { + echo "LAF_PUBLISHER_ID=${{ secrets.LAF_PUBLISHER_ID }}" >> $env:GITHUB_ENV + } + - name: Restore dependencies + run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:PublishReadyToRun=true + - name: Build + run: | + dotnet build AIDevGallery.Utils --no-restore /p:Configuration=${{ matrix.dotnet-configuration }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" + dotnet build AIDevGallery --no-restore -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" + - name: Setup Dev Tools + uses: ilammy/msvc-dev-cmd@v1 + - name: Build Test Suite + run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} + - name: Run All Tests + run: | + # Create TestResults directory if it doesn't exist + New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null + + # Run all tests (unit, integration, and UI tests) + dotnet test AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.dll ` + --logger "trx;LogFileName=${{ github.workspace }}\TestResults\RegressionTests.trx" ` + --logger "console;verbosity=detailed" ` + --verbosity normal + + # Check test results + if ($LASTEXITCODE -ne 0) { + Write-Warning "Tests failed with exit code $LASTEXITCODE. Artifacts will be uploaded for debugging." + } + - name: Publish Test Builds If Failed + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-builds-${{ matrix.dotnet-arch }} + path: | + .\AIDevGallery\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} + .\AIDevGallery\obj\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} + .\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} + .\AIDevGallery.Tests\obj\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }} + - name: Publish Test Results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: regression-test-results-${{ matrix.dotnet-arch }} + path: TestResults + - name: Display Test Report + if: ${{ !cancelled() }} + uses: dorny/test-reporter@v2 + with: + name: Regression Test Report - ${{ matrix.dotnet-arch }} + path: '${{ github.workspace }}\TestResults\*.trx' + reporter: dotnet-trx + fail-on-error: false diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml deleted file mode 100644 index c98d5b67..00000000 --- a/.github/workflows/test-report.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: 'Test Report' -on: - workflow_run: - workflows: ['CI'] # runs after CI workflow - types: - - completed -permissions: - contents: read - actions: read - checks: write -jobs: - report: - strategy: - fail-fast: false - matrix: - include: - - os: windows-2025 - dotnet-arch: 'x64' - - os: windows-11-arm - dotnet-arch: 'arm64' - name: Test - ${{ matrix.dotnet-arch }} - runs-on: ${{ matrix.os }} - steps: - - name: Display test results - uses: dorny/test-reporter@v2 - with: - artifact: test-results-${{ matrix.dotnet-arch }} - name: Test - Results - ${{ matrix.dotnet-arch }} - path: '*.trx' - reporter: dotnet-trx \ No newline at end of file From c8bb9e4da2be67dec1966fe710777a919bf59ae0 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 11:33:49 +0800 Subject: [PATCH 045/115] update --- .github/workflows/build.yml | 2 +- .../ExcludeDuplicateResources.props | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 AIDevGallery.Tests/ExcludeDuplicateResources.props diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 635ff806..fcc6a7bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,7 @@ jobs: uses: dorny/test-reporter@v2 with: name: Unit Test Report - ${{ matrix.dotnet-arch }} - path: '${{ github.workspace }}\TestResults\*.trx' + path: 'TestResults/*.trx' reporter: dotnet-trx fail-on-error: false diff --git a/AIDevGallery.Tests/ExcludeDuplicateResources.props b/AIDevGallery.Tests/ExcludeDuplicateResources.props new file mode 100644 index 00000000..772c238d --- /dev/null +++ b/AIDevGallery.Tests/ExcludeDuplicateResources.props @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + From c81d470d95ca89f97e40b1915c6395bc90f72c18 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 11:48:05 +0800 Subject: [PATCH 046/115] rename --- .github/workflows/{regression-tests.yml => test-report.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{regression-tests.yml => test-report.yml} (100%) diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/test-report.yml similarity index 100% rename from .github/workflows/regression-tests.yml rename to .github/workflows/test-report.yml From 4e6290613caeb78ca76a594fd9a8d3456d0d3b87 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 13:24:04 +0800 Subject: [PATCH 047/115] temp: add pull_request trigger for testing --- .github/workflows/test-report.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 6b707f47..08e5d53f 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -2,6 +2,8 @@ name: Regression Tests on: workflow_dispatch: + pull_request: + branches: [ "main" ] jobs: regression-tests: From e3c324e632e275ac604453bc0ec1353486640002 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 13:37:55 +0800 Subject: [PATCH 048/115] update --- .github/workflows/build.yml | 7 ++++++ .github/workflows/test-report.yml | 36 +++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fcc6a7bf..d4563a61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -133,6 +133,13 @@ jobs: with: name: MSIX-${{ matrix.dotnet-arch }} path: ${{ github.workspace }}/AIDevGallery/AppPackages/*_${{ matrix.dotnet-arch }}_Test/AIDevGallery_*_${{ matrix.dotnet-arch }}.msix + - name: Upload Build Artifacts for Testing + uses: actions/upload-artifact@v4 + with: + name: build-output-${{ matrix.dotnet-arch }} + path: | + ${{ github.workspace }}/AIDevGallery/bin/${{ matrix.dotnet-arch }}/${{ matrix.dotnet-configuration }} + ${{ github.workspace }}/AIDevGallery.Tests/bin/${{ matrix.dotnet-arch }}/${{ matrix.dotnet-configuration }} accessibility-check: needs: build diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 08e5d53f..828099ec 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -4,9 +4,15 @@ on: workflow_dispatch: pull_request: branches: [ "main" ] + workflow_run: + workflows: ["CI"] + types: + - completed jobs: regression-tests: + # Only run if workflow_run was successful, or if manually triggered or on PR + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} timeout-minutes: 60 strategy: fail-fast: false @@ -23,27 +29,49 @@ jobs: permissions: contents: read checks: write + actions: read steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Download Build Artifacts + if: github.event_name == 'workflow_run' + uses: actions/download-artifact@v4 + with: + name: build-output-${{ matrix.dotnet-arch }} + path: downloaded-artifacts + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Copy Downloaded Artifacts to Workspace + if: github.event_name == 'workflow_run' + run: | + # Copy AIDevGallery build output + Copy-Item -Path "downloaded-artifacts\AIDevGallery\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}" -Destination "${{ github.workspace }}\AIDevGallery\bin\${{ matrix.dotnet-arch }}\" -Recurse -Force + # Copy Tests build output + Copy-Item -Path "downloaded-artifacts\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}" -Destination "${{ github.workspace }}\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\" -Recurse -Force - name: Setup .NET + if: github.event_name != 'workflow_run' uses: actions/setup-dotnet@v4 with: dotnet-version: 9.0.x - name: Add msbuild to PATH + if: github.event_name != 'workflow_run' uses: microsoft/setup-msbuild@v2 - name: Install NBGV tool + if: github.event_name != 'workflow_run' run: dotnet tool install --tool-path . nbgv - name: Set Version + if: github.event_name != 'workflow_run' run: ./nbgv cloud -c - name: Update Package Manifest Version + if: github.event_name != 'workflow_run' run: | $manifestPath = "${{ github.workspace }}\AIDevGallery\Package.appxmanifest" [xml]$manifest = get-content $manifestPath $manifest.Package.Identity.Version = '${{ env.GitBuildVersionSimple }}.0' $manifest.Save($manifestPath) - name: Set LAF build props + if: github.event_name != 'workflow_run' shell: pwsh run: | if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_TOKEN }}")) { @@ -53,14 +81,18 @@ jobs: echo "LAF_PUBLISHER_ID=${{ secrets.LAF_PUBLISHER_ID }}" >> $env:GITHUB_ENV } - name: Restore dependencies - run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:PublishReadyToRun=true + if: github.event_name != 'workflow_run' + run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:PublishReadyToRun=true /p:SelfContainedIfPreviewWASDK=true - name: Build + if: github.event_name != 'workflow_run' run: | dotnet build AIDevGallery.Utils --no-restore /p:Configuration=${{ matrix.dotnet-configuration }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" - dotnet build AIDevGallery --no-restore -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" + dotnet build AIDevGallery --no-restore -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:AppxPackageDir="AppPackages/" /p:UapAppxPackageBuildMode=SideloadOnly /p:AppxBundle=Never /p:GenerateAppxPackageOnBuild=true /p:SelfContainedIfPreviewWASDK=true /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" - name: Setup Dev Tools + if: github.event_name != 'workflow_run' uses: ilammy/msvc-dev-cmd@v1 - name: Build Test Suite + if: github.event_name != 'workflow_run' run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Run All Tests run: | From c41b01bc057dc4f80ae474017d64b4e6ddf2e45c Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 13:51:47 +0800 Subject: [PATCH 049/115] update --- .github/workflows/test-report.yml | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 828099ec..51c8ff54 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -34,6 +34,14 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Download MSIX Artifact (from workflow_run) + if: github.event_name == 'workflow_run' + uses: actions/download-artifact@v4 + with: + name: MSIX-${{ matrix.dotnet-arch }} + path: ./DownloadedMSIX + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Download Build Artifacts if: github.event_name == 'workflow_run' uses: actions/download-artifact@v4 @@ -49,6 +57,70 @@ jobs: Copy-Item -Path "downloaded-artifacts\AIDevGallery\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}" -Destination "${{ github.workspace }}\AIDevGallery\bin\${{ matrix.dotnet-arch }}\" -Recurse -Force # Copy Tests build output Copy-Item -Path "downloaded-artifacts\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}" -Destination "${{ github.workspace }}\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\" -Recurse -Force + - name: Deploy MSIX Package + shell: powershell + run: | + $ErrorActionPreference = "Stop" + + # Find MSIX - either from downloaded artifact or local build + if ("${{ github.event_name }}" -eq "workflow_run") { + $downloadPath = Join-Path (Get-Location) "DownloadedMSIX" + Write-Host "Looking for MSIX in downloaded artifacts: $downloadPath" + $msixFile = Get-ChildItem -Path $downloadPath -Filter "*.msix" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + } else { + $appPackagesPath = Join-Path "${{ github.workspace }}" "AIDevGallery\AppPackages" + Write-Host "Looking for MSIX in local build: $appPackagesPath" + $msixFile = Get-ChildItem -Path $appPackagesPath -Filter "*.msix" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + } + + if (-not $msixFile) { + Write-Error "MSIX file not found" + exit 1 + } + + Write-Host "Using MSIX file: $($msixFile.FullName)" + + # Cleanup previous install + Get-AppxPackage -Name "*AIDevGallery*" | Remove-AppxPackage -ErrorAction SilentlyContinue + Get-AppxPackage -Name "*e7af07c0*" | Remove-AppxPackage -ErrorAction SilentlyContinue + + # Ensure makeappx.exe is available + if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { + Write-Host "makeappx.exe not found in PATH. Searching in Windows Kits..." + $kitsRoot = "C:\Program Files (x86)\Windows Kits\10\bin" + if (Test-Path $kitsRoot) { + $latestSdk = Get-ChildItem -Path $kitsRoot -Directory | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object Name -Descending | + Select-Object -First 1 + + if ($latestSdk) { + $sdkPath = Join-Path $latestSdk.FullName "x64" + if (Test-Path $sdkPath) { + Write-Host "Found SDK at $sdkPath" + $env:Path = "$sdkPath;$env:Path" + } + } + } + } + + if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { + Write-Error "makeappx.exe could not be found. Please ensure Windows SDK is installed." + exit 1 + } + + # Unpack MSIX + $unpackedDir = Join-Path $PWD "AIDevGallery_Unpacked" + if (Test-Path $unpackedDir) { Remove-Item -Path $unpackedDir -Recurse -Force } + Write-Host "Unpacking MSIX to $unpackedDir..." + makeappx.exe unpack /p $msixFile.FullName /d $unpackedDir + + # Register Manifest + $manifestPath = Join-Path $unpackedDir "AppxManifest.xml" + Write-Host "Registering AppxManifest.xml from $manifestPath..." + Add-AppxPackage -Register $manifestPath -ForceUpdateFromAnyVersion + + Write-Host "MSIX package deployed successfully" - name: Setup .NET if: github.event_name != 'workflow_run' uses: actions/setup-dotnet@v4 From fa7a52649182ff5cba60d6d03929cede6cd8e1ee Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 14:02:01 +0800 Subject: [PATCH 050/115] Fix: Move MSIX deployment after build steps --- .github/workflows/test-report.yml | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 51c8ff54..f9fbb05b 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -57,6 +57,51 @@ jobs: Copy-Item -Path "downloaded-artifacts\AIDevGallery\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}" -Destination "${{ github.workspace }}\AIDevGallery\bin\${{ matrix.dotnet-arch }}\" -Recurse -Force # Copy Tests build output Copy-Item -Path "downloaded-artifacts\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}" -Destination "${{ github.workspace }}\AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\" -Recurse -Force + - name: Setup .NET + if: github.event_name != 'workflow_run' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Add msbuild to PATH + if: github.event_name != 'workflow_run' + uses: microsoft/setup-msbuild@v2 + - name: Install NBGV tool + if: github.event_name != 'workflow_run' + run: dotnet tool install --tool-path . nbgv + - name: Set Version + if: github.event_name != 'workflow_run' + run: ./nbgv cloud -c + - name: Update Package Manifest Version + if: github.event_name != 'workflow_run' + run: | + $manifestPath = "${{ github.workspace }}\AIDevGallery\Package.appxmanifest" + [xml]$manifest = get-content $manifestPath + $manifest.Package.Identity.Version = '${{ env.GitBuildVersionSimple }}.0' + $manifest.Save($manifestPath) + - name: Set LAF build props + if: github.event_name != 'workflow_run' + shell: pwsh + run: | + if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_TOKEN }}")) { + echo "LAF_TOKEN=${{ secrets.LAF_TOKEN }}" >> $env:GITHUB_ENV + } + if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_PUBLISHER_ID }}")) { + echo "LAF_PUBLISHER_ID=${{ secrets.LAF_PUBLISHER_ID }}" >> $env:GITHUB_ENV + } + - name: Restore dependencies + if: github.event_name != 'workflow_run' + run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:PublishReadyToRun=true /p:SelfContainedIfPreviewWASDK=true + - name: Build + if: github.event_name != 'workflow_run' + run: | + dotnet build AIDevGallery.Utils --no-restore /p:Configuration=${{ matrix.dotnet-configuration }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" + dotnet build AIDevGallery --no-restore -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:AppxPackageDir="AppPackages/" /p:UapAppxPackageBuildMode=SideloadOnly /p:AppxBundle=Never /p:GenerateAppxPackageOnBuild=true /p:SelfContainedIfPreviewWASDK=true /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" + - name: Setup Dev Tools + if: github.event_name != 'workflow_run' + uses: ilammy/msvc-dev-cmd@v1 + - name: Build Test Suite + if: github.event_name != 'workflow_run' + run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Deploy MSIX Package shell: powershell run: | From dfdc4fd7c21f0d80781979d10ebee4b116395b20 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 14:08:01 +0800 Subject: [PATCH 051/115] Remove pull_request trigger to avoid parallel execution with CI --- .github/workflows/test-report.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index f9fbb05b..4e1c5bbc 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -2,8 +2,6 @@ name: Regression Tests on: workflow_dispatch: - pull_request: - branches: [ "main" ] workflow_run: workflows: ["CI"] types: From 916230b60cf6a24d25ec63e9ae2ab8a6fa8ebff4 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 16:36:41 +0800 Subject: [PATCH 052/115] Fix test report path format --- .github/workflows/test-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 4e1c5bbc..8b38c182 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -245,6 +245,6 @@ jobs: uses: dorny/test-reporter@v2 with: name: Regression Test Report - ${{ matrix.dotnet-arch }} - path: '${{ github.workspace }}\TestResults\*.trx' + path: 'TestResults/*.trx' reporter: dotnet-trx fail-on-error: false From bfe802da2b225bab1c147319cf47faebf83eea64 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 18:24:45 +0800 Subject: [PATCH 053/115] Remove CI trigger --- .github/workflows/test-report.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 8b38c182..9fa3dcca 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -2,10 +2,10 @@ name: Regression Tests on: workflow_dispatch: - workflow_run: - workflows: ["CI"] - types: - - completed + #workflow_run: + # workflows: ["CI"] + # types: + # - completed jobs: regression-tests: From 416d8c2047f93de7f69fe8fd57ba15b8862098fc Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 18:37:37 +0800 Subject: [PATCH 054/115] remove Upload Build Artifacts for Testing --- .github/workflows/build.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d4563a61..fcc6a7bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -133,13 +133,6 @@ jobs: with: name: MSIX-${{ matrix.dotnet-arch }} path: ${{ github.workspace }}/AIDevGallery/AppPackages/*_${{ matrix.dotnet-arch }}_Test/AIDevGallery_*_${{ matrix.dotnet-arch }}.msix - - name: Upload Build Artifacts for Testing - uses: actions/upload-artifact@v4 - with: - name: build-output-${{ matrix.dotnet-arch }} - path: | - ${{ github.workspace }}/AIDevGallery/bin/${{ matrix.dotnet-arch }}/${{ matrix.dotnet-configuration }} - ${{ github.workspace }}/AIDevGallery.Tests/bin/${{ matrix.dotnet-arch }}/${{ matrix.dotnet-configuration }} accessibility-check: needs: build From d96d43f0042f67ba5a733c8be14e35741c6cc008 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 18 Dec 2025 19:55:44 +0800 Subject: [PATCH 055/115] update --- .github/scripts/deploy-msix.ps1 | 120 +++++++++++++++++ .github/workflows/test-report.yml | 125 +++--------------- AIDevGallery.Tests/TestInfra/FlaUITestBase.cs | 24 +++- .../TestInfra/NativeUIA3TestBase.cs | 8 +- 4 files changed, 169 insertions(+), 108 deletions(-) create mode 100644 .github/scripts/deploy-msix.ps1 diff --git a/.github/scripts/deploy-msix.ps1 b/.github/scripts/deploy-msix.ps1 new file mode 100644 index 00000000..9bd5e162 --- /dev/null +++ b/.github/scripts/deploy-msix.ps1 @@ -0,0 +1,120 @@ +<# +.SYNOPSIS + Deploys MSIX package for testing by unpacking and registering the manifest. + +.DESCRIPTION + This script finds the MSIX package (from downloaded artifacts or local build), + unpacks it, and registers the AppxManifest.xml for testing purposes. + It handles cleanup of previous installations and ensures makeappx.exe is available. + +.PARAMETER MsixSearchPath + The path to search for MSIX files. Can be a directory from downloaded artifacts or local build. + +.PARAMETER IsWorkflowRun + Boolean indicating if this is running as part of a workflow_run event. + +.EXAMPLE + .\deploy-msix.ps1 -MsixSearchPath ".\DownloadedMSIX" -IsWorkflowRun $true + .\deploy-msix.ps1 -MsixSearchPath ".\AIDevGallery\AppPackages" -IsWorkflowRun $false +#> + +param( + [Parameter(Mandatory=$true)] + [string]$MsixSearchPath, + + [Parameter(Mandatory=$false)] + [bool]$IsWorkflowRun = $false +) + +$ErrorActionPreference = "Stop" + +Write-Host "=== MSIX Deployment Script ===" +Write-Host "Search Path: $MsixSearchPath" +Write-Host "Is Workflow Run: $IsWorkflowRun" +Write-Host "" + +# Find MSIX file +Write-Host "Looking for MSIX file in: $MsixSearchPath" +$msixFile = Get-ChildItem -Path $MsixSearchPath -Filter "*.msix" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + +if (-not $msixFile) { + Write-Error "MSIX file not found in path: $MsixSearchPath" + exit 1 +} + +Write-Host "Using MSIX file: $($msixFile.FullName)" +Write-Host "" + +# Cleanup previous installations +Write-Host "Cleaning up previous installations..." +Get-AppxPackage -Name "*AIDevGallery*" | Remove-AppxPackage -ErrorAction SilentlyContinue +Get-AppxPackage -Name "*e7af07c0*" | Remove-AppxPackage -ErrorAction SilentlyContinue +Write-Host "Cleanup completed" +Write-Host "" + +# Ensure makeappx.exe is available +Write-Host "Checking for makeappx.exe..." +if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { + Write-Host "makeappx.exe not found in PATH. Searching in Windows Kits..." + $kitsRoot = "C:\Program Files (x86)\Windows Kits\10\bin" + + if (Test-Path $kitsRoot) { + $latestSdk = Get-ChildItem -Path $kitsRoot -Directory | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object Name -Descending | + Select-Object -First 1 + + if ($latestSdk) { + $sdkPath = Join-Path $latestSdk.FullName "x64" + if (Test-Path $sdkPath) { + Write-Host "Found SDK at $sdkPath" + $env:Path = "$sdkPath;$env:Path" + } + } + } +} + +if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { + Write-Error "makeappx.exe could not be found. Please ensure Windows SDK is installed." + exit 1 +} + +Write-Host "makeappx.exe found" +Write-Host "" + +# Unpack MSIX +$unpackedDir = Join-Path $PWD "AIDevGallery_Unpacked" +if (Test-Path $unpackedDir) { + Write-Host "Removing existing unpacked directory..." + Remove-Item -Path $unpackedDir -Recurse -Force +} + +Write-Host "Unpacking MSIX to $unpackedDir..." +makeappx.exe unpack /p $msixFile.FullName /d $unpackedDir + +if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to unpack MSIX. Exit code: $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host "MSIX unpacked successfully" +Write-Host "" + +# Register Manifest +$manifestPath = Join-Path $unpackedDir "AppxManifest.xml" +if (-not (Test-Path $manifestPath)) { + Write-Error "AppxManifest.xml not found at: $manifestPath" + exit 1 +} + +Write-Host "Registering AppxManifest.xml from $manifestPath..." +Add-AppxPackage -Register $manifestPath -ForceUpdateFromAnyVersion + +if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to register AppxPackage. Exit code: $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "=== MSIX package deployed successfully ===" +exit 0 diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 9fa3dcca..44181715 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -2,15 +2,18 @@ name: Regression Tests on: workflow_dispatch: - #workflow_run: - # workflows: ["CI"] - # types: - # - completed + # TODO: Enable workflow_run trigger after validating the workflow works reliably + # This would allow automatic testing after successful CI builds + # workflow_run: + # workflows: ["CI"] + # types: + # - completed jobs: regression-tests: - # Only run if workflow_run was successful, or if manually triggered or on PR - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + # Currently only supports manual triggering via workflow_dispatch + # When workflow_run is enabled, add condition: github.event.workflow_run.conclusion == 'success' + if: ${{ github.event_name == 'workflow_dispatch' }} timeout-minutes: 60 strategy: fail-fast: false @@ -103,112 +106,22 @@ jobs: - name: Deploy MSIX Package shell: powershell run: | - $ErrorActionPreference = "Stop" - - # Find MSIX - either from downloaded artifact or local build + # Determine MSIX search path based on event type if ("${{ github.event_name }}" -eq "workflow_run") { - $downloadPath = Join-Path (Get-Location) "DownloadedMSIX" - Write-Host "Looking for MSIX in downloaded artifacts: $downloadPath" - $msixFile = Get-ChildItem -Path $downloadPath -Filter "*.msix" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + $msixSearchPath = Join-Path (Get-Location) "DownloadedMSIX" } else { - $appPackagesPath = Join-Path "${{ github.workspace }}" "AIDevGallery\AppPackages" - Write-Host "Looking for MSIX in local build: $appPackagesPath" - $msixFile = Get-ChildItem -Path $appPackagesPath -Filter "*.msix" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - } - - if (-not $msixFile) { - Write-Error "MSIX file not found" - exit 1 - } - - Write-Host "Using MSIX file: $($msixFile.FullName)" - - # Cleanup previous install - Get-AppxPackage -Name "*AIDevGallery*" | Remove-AppxPackage -ErrorAction SilentlyContinue - Get-AppxPackage -Name "*e7af07c0*" | Remove-AppxPackage -ErrorAction SilentlyContinue - - # Ensure makeappx.exe is available - if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { - Write-Host "makeappx.exe not found in PATH. Searching in Windows Kits..." - $kitsRoot = "C:\Program Files (x86)\Windows Kits\10\bin" - if (Test-Path $kitsRoot) { - $latestSdk = Get-ChildItem -Path $kitsRoot -Directory | - Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | - Sort-Object Name -Descending | - Select-Object -First 1 - - if ($latestSdk) { - $sdkPath = Join-Path $latestSdk.FullName "x64" - if (Test-Path $sdkPath) { - Write-Host "Found SDK at $sdkPath" - $env:Path = "$sdkPath;$env:Path" - } - } - } + $msixSearchPath = Join-Path "${{ github.workspace }}" "AIDevGallery\AppPackages" } - if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { - Write-Error "makeappx.exe could not be found. Please ensure Windows SDK is installed." - exit 1 - } - - # Unpack MSIX - $unpackedDir = Join-Path $PWD "AIDevGallery_Unpacked" - if (Test-Path $unpackedDir) { Remove-Item -Path $unpackedDir -Recurse -Force } - Write-Host "Unpacking MSIX to $unpackedDir..." - makeappx.exe unpack /p $msixFile.FullName /d $unpackedDir + # Call the deployment script + & "${{ github.workspace }}\.github\scripts\deploy-msix.ps1" ` + -MsixSearchPath $msixSearchPath ` + -IsWorkflowRun $("${{ github.event_name }}" -eq "workflow_run") - # Register Manifest - $manifestPath = Join-Path $unpackedDir "AppxManifest.xml" - Write-Host "Registering AppxManifest.xml from $manifestPath..." - Add-AppxPackage -Register $manifestPath -ForceUpdateFromAnyVersion - - Write-Host "MSIX package deployed successfully" - - name: Setup .NET - if: github.event_name != 'workflow_run' - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Add msbuild to PATH - if: github.event_name != 'workflow_run' - uses: microsoft/setup-msbuild@v2 - - name: Install NBGV tool - if: github.event_name != 'workflow_run' - run: dotnet tool install --tool-path . nbgv - - name: Set Version - if: github.event_name != 'workflow_run' - run: ./nbgv cloud -c - - name: Update Package Manifest Version - if: github.event_name != 'workflow_run' - run: | - $manifestPath = "${{ github.workspace }}\AIDevGallery\Package.appxmanifest" - [xml]$manifest = get-content $manifestPath - $manifest.Package.Identity.Version = '${{ env.GitBuildVersionSimple }}.0' - $manifest.Save($manifestPath) - - name: Set LAF build props - if: github.event_name != 'workflow_run' - shell: pwsh - run: | - if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_TOKEN }}")) { - echo "LAF_TOKEN=${{ secrets.LAF_TOKEN }}" >> $env:GITHUB_ENV - } - if (-not [string]::IsNullOrWhiteSpace("${{ secrets.LAF_PUBLISHER_ID }}")) { - echo "LAF_PUBLISHER_ID=${{ secrets.LAF_PUBLISHER_ID }}" >> $env:GITHUB_ENV + if ($LASTEXITCODE -ne 0) { + Write-Error "MSIX deployment failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE } - - name: Restore dependencies - if: github.event_name != 'workflow_run' - run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:PublishReadyToRun=true /p:SelfContainedIfPreviewWASDK=true - - name: Build - if: github.event_name != 'workflow_run' - run: | - dotnet build AIDevGallery.Utils --no-restore /p:Configuration=${{ matrix.dotnet-configuration }} /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" - dotnet build AIDevGallery --no-restore -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} /p:AppxPackageDir="AppPackages/" /p:UapAppxPackageBuildMode=SideloadOnly /p:AppxBundle=Never /p:GenerateAppxPackageOnBuild=true /p:SelfContainedIfPreviewWASDK=true /p:LafToken="${{ env.LAF_TOKEN }}" /p:LafPublisherId="${{ env.LAF_PUBLISHER_ID }}" - - name: Setup Dev Tools - if: github.event_name != 'workflow_run' - uses: ilammy/msvc-dev-cmd@v1 - - name: Build Test Suite - if: github.event_name != 'workflow_run' - run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Run All Tests run: | # Create TestResults directory if it doesn't exist diff --git a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs index 9a7f721f..91c8f036 100644 --- a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs +++ b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs @@ -19,6 +19,12 @@ namespace AIDevGallery.Tests.TestInfra; /// public abstract class FlaUITestBase { + /// + /// The MSIX package identity name GUID from Package.appxmanifest. + /// This is the unique identifier used to locate the installed MSIX package. + /// + private const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; + protected Application? App { get; private set; } protected UIA3Automation? Automation { get; private set; } protected Window? MainWindow { get; private set; } @@ -101,7 +107,7 @@ protected virtual string GetApplicationPath() var startInfo = new ProcessStartInfo { FileName = "powershell.exe", - Arguments = "-NoProfile -Command \"Get-AppxPackage | Where-Object {$_.Name -like '*e7af07c0-77d2-43e5-ab82-9cdb9daa11b3*'} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", + Arguments = $"-NoProfile -Command \"Get-AppxPackage | Where-Object {{$_.Name -like '*{MsixPackageIdentityName}*'}} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -218,6 +224,22 @@ public virtual void TestInitialize() App = Application.Attach(appProcess.Id); Console.WriteLine($"Attached to application with PID: {App.ProcessId}"); } + catch (UnauthorizedAccessException ex) + { + throw new InvalidOperationException( + $"Access denied when launching MSIX package: {packageFamilyName}{Environment.NewLine}" + + $"Error: {ex.Message}{Environment.NewLine}" + + $"Try running Visual Studio or the test runner as Administrator.", + ex); + } + catch (System.ComponentModel.Win32Exception ex) + { + throw new InvalidOperationException( + $"Failed to launch MSIX package via PowerShell: {packageFamilyName}{Environment.NewLine}" + + $"Error: {ex.Message}{Environment.NewLine}" + + $"Ensure PowerShell is available and the package is properly installed.", + ex); + } catch (Exception ex) { Console.WriteLine($"Failed to launch via MSIX: {ex.Message}"); diff --git a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs index a89edacc..4481b050 100644 --- a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs +++ b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs @@ -18,6 +18,12 @@ namespace AIDevGallery.Tests.TestInfra; /// public abstract class NativeUIA3TestBase { + /// + /// The MSIX package identity name GUID from Package.appxmanifest. + /// This is the unique identifier used to locate the installed MSIX package. + /// + private const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; + protected Process? AppProcess { get; private set; } protected CUIAutomation? Automation { get; private set; } protected IUIAutomationElement? MainWindow { get; private set; } @@ -90,7 +96,7 @@ protected virtual string GetApplicationPath() var startInfo = new ProcessStartInfo { FileName = "powershell.exe", - Arguments = "-NoProfile -Command \"Get-AppxPackage | Where-Object {$_.Name -like '*e7af07c0-77d2-43e5-ab82-9cdb9daa11b3*'} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", + Arguments = $"-NoProfile -Command \"Get-AppxPackage | Where-Object {{$_.Name -like '*{MsixPackageIdentityName}*'}} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, From d3a39dbc27476f93b12583a07ad6d090a41297bb Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 18 Dec 2025 21:43:30 +0800 Subject: [PATCH 056/115] update --- AIDevGallery.Tests/TestInfra/FlaUITestBase.cs | 7 +------ .../TestInfra/NativeUIA3TestBase.cs | 7 +------ AIDevGallery.Tests/TestInfra/TestConfiguration.cs | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 AIDevGallery.Tests/TestInfra/TestConfiguration.cs diff --git a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs index 91c8f036..004fbd38 100644 --- a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs +++ b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs @@ -19,11 +19,6 @@ namespace AIDevGallery.Tests.TestInfra; /// public abstract class FlaUITestBase { - /// - /// The MSIX package identity name GUID from Package.appxmanifest. - /// This is the unique identifier used to locate the installed MSIX package. - /// - private const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; protected Application? App { get; private set; } protected UIA3Automation? Automation { get; private set; } @@ -107,7 +102,7 @@ protected virtual string GetApplicationPath() var startInfo = new ProcessStartInfo { FileName = "powershell.exe", - Arguments = $"-NoProfile -Command \"Get-AppxPackage | Where-Object {{$_.Name -like '*{MsixPackageIdentityName}*'}} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", + Arguments = $"-NoProfile -Command \"Get-AppxPackage | Where-Object {{$_.Name -like '*{TestConfiguration.MsixPackageIdentityName}*'}} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs index 4481b050..84b6b499 100644 --- a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs +++ b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs @@ -18,11 +18,6 @@ namespace AIDevGallery.Tests.TestInfra; /// public abstract class NativeUIA3TestBase { - /// - /// The MSIX package identity name GUID from Package.appxmanifest. - /// This is the unique identifier used to locate the installed MSIX package. - /// - private const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; protected Process? AppProcess { get; private set; } protected CUIAutomation? Automation { get; private set; } @@ -96,7 +91,7 @@ protected virtual string GetApplicationPath() var startInfo = new ProcessStartInfo { FileName = "powershell.exe", - Arguments = $"-NoProfile -Command \"Get-AppxPackage | Where-Object {{$_.Name -like '*{MsixPackageIdentityName}*'}} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", + Arguments = $"-NoProfile -Command \"Get-AppxPackage | Where-Object {{$_.Name -like '*{TestConfiguration.MsixPackageIdentityName}*'}} | Select-Object -First 1 -ExpandProperty PackageFamilyName\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs new file mode 100644 index 00000000..92138458 --- /dev/null +++ b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace AIDevGallery.Tests.TestInfra; + +/// +/// Central configuration for test infrastructure. +/// +public static class TestConfiguration +{ + /// + /// The MSIX package identity name GUID from Package.appxmanifest. + /// + public const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; +} From 2c92bbf5fc23298de906e1a4bb7587ec825a9d07 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 18 Dec 2025 22:11:03 +0800 Subject: [PATCH 057/115] optimize unit test workflow with coverage and conditional artifacts --- .github/workflows/build.yml | 39 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fcc6a7bf..33fa21ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ on: jobs: pr-unit-test: if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main') - timeout-minutes: 10 + timeout-minutes: 5 strategy: fail-fast: false matrix: @@ -41,9 +41,9 @@ jobs: uses: actions/cache@v4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }} + key: ${{ runner.os }}-${{ matrix.dotnet-arch }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }} restore-keys: | - ${{ runner.os }}-nuget- + ${{ runner.os }}-${{ matrix.dotnet-arch }}-nuget- - name: Restore dependencies run: dotnet restore AIDevGallery.sln -r win-${{ matrix.dotnet-arch }} /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Build AIDevGallery.Utils @@ -51,24 +51,28 @@ jobs: - name: Build Test Project run: dotnet build AIDevGallery.Tests -r win-${{ matrix.dotnet-arch }} -f net9.0-windows10.0.26100.0 /p:Configuration=${{ matrix.dotnet-configuration }} /p:Platform=${{ matrix.dotnet-arch }} - name: Run Unit Tests + shell: pwsh run: | - New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null - dotnet test AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.dll ` + dotnet test AIDevGallery.Tests ` + --no-build ` + -r win-${{ matrix.dotnet-arch }} ` + -f net9.0-windows10.0.26100.0 ` + /p:Configuration=${{ matrix.dotnet-configuration }} ` + /p:Platform=${{ matrix.dotnet-arch }} ` --filter "FullyQualifiedName~UnitTests" ` --logger "trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" ` --logger "console;verbosity=detailed" ` - --verbosity normal - - if ($LASTEXITCODE -ne 0) { - Write-Error "Unit tests failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE - } + --collect:"XPlat Code Coverage" ` + --results-directory TestResults ` + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover - name: Publish Test Results - if: always() + if: failure() uses: actions/upload-artifact@v4 with: name: pr-unit-test-results-${{ matrix.dotnet-arch }} - path: TestResults + path: | + TestResults + **/coverage.opencover.xml - name: Display Test Report if: always() uses: dorny/test-reporter@v2 @@ -77,6 +81,15 @@ jobs: path: 'TestResults/*.trx' reporter: dotnet-trx fail-on-error: false + + - name: Code Coverage Summary + if: always() + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: '**/coverage.opencover.xml' + badge: true + format: markdown + output: both build: strategy: From 6c1eb70dc1767c78abcb087f94e2ca90ed2afae3 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 18 Dec 2025 22:17:06 +0800 Subject: [PATCH 058/115] update --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33fa21ca..abd81244 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ on: jobs: pr-unit-test: if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.base_ref == 'main') - timeout-minutes: 5 + timeout-minutes: 15 strategy: fail-fast: false matrix: From 928117cedaa9df98d21e29523ed703f21c787460 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 18 Dec 2025 22:31:23 +0800 Subject: [PATCH 059/115] update --- AIDevGallery.Tests/TestInfra/FlaUITestBase.cs | 1 - AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs | 1 - AIDevGallery.Tests/TestInfra/TestConfiguration.cs | 1 + 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs index 004fbd38..6a72cd5a 100644 --- a/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs +++ b/AIDevGallery.Tests/TestInfra/FlaUITestBase.cs @@ -19,7 +19,6 @@ namespace AIDevGallery.Tests.TestInfra; /// public abstract class FlaUITestBase { - protected Application? App { get; private set; } protected UIA3Automation? Automation { get; private set; } protected Window? MainWindow { get; private set; } diff --git a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs index 84b6b499..975c358a 100644 --- a/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs +++ b/AIDevGallery.Tests/TestInfra/NativeUIA3TestBase.cs @@ -18,7 +18,6 @@ namespace AIDevGallery.Tests.TestInfra; /// public abstract class NativeUIA3TestBase { - protected Process? AppProcess { get; private set; } protected CUIAutomation? Automation { get; private set; } protected IUIAutomationElement? MainWindow { get; private set; } diff --git a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs index 92138458..88a771c2 100644 --- a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs +++ b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs @@ -13,3 +13,4 @@ public static class TestConfiguration /// public const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; } + From 6d476b8782ebec92a241705b9145fc7322a8b05f Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 22:56:15 +0800 Subject: [PATCH 060/115] format --- AIDevGallery.Tests/TestInfra/TestConfiguration.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs index 88a771c2..122ac604 100644 --- a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs +++ b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs @@ -12,5 +12,4 @@ public static class TestConfiguration /// The MSIX package identity name GUID from Package.appxmanifest. /// public const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; -} - +} \ No newline at end of file From 2ad691da22278995e2936e18fa8a58e1ee1e6268 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 23:14:19 +0800 Subject: [PATCH 061/115] format --- .github/workflows/build.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index abd81244..7544b4c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,7 +60,7 @@ jobs: /p:Configuration=${{ matrix.dotnet-configuration }} ` /p:Platform=${{ matrix.dotnet-arch }} ` --filter "FullyQualifiedName~UnitTests" ` - --logger "trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" ` + --logger "trx;LogFileName=UnitTestResults.trx" ` --logger "console;verbosity=detailed" ` --collect:"XPlat Code Coverage" ` --results-directory TestResults ` @@ -81,15 +81,6 @@ jobs: path: 'TestResults/*.trx' reporter: dotnet-trx fail-on-error: false - - - name: Code Coverage Summary - if: always() - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: '**/coverage.opencover.xml' - badge: true - format: markdown - output: both build: strategy: From 9571287ec4ea9312dfde9168aa4fface1d0d6795 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 23:26:54 +0800 Subject: [PATCH 062/115] test --- .github/workflows/build.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7544b4c8..8bf3f975 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,6 +65,16 @@ jobs: --collect:"XPlat Code Coverage" ` --results-directory TestResults ` -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover + - name: List Test Results + if: always() + shell: pwsh + run: | + Write-Host "Listing all files in TestResults directory:" + if (Test-Path TestResults) { + Get-ChildItem -Path TestResults -Recurse | ForEach-Object { Write-Host $_.FullName } + } else { + Write-Host "TestResults directory does not exist!" + } - name: Publish Test Results if: failure() uses: actions/upload-artifact@v4 @@ -78,7 +88,7 @@ jobs: uses: dorny/test-reporter@v2 with: name: Unit Test Report - ${{ matrix.dotnet-arch }} - path: 'TestResults/*.trx' + path: '**/TestResults/*.trx' reporter: dotnet-trx fail-on-error: false From 006a9561080770f1a959ce1d037fc39ff6df1edf Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 23:35:44 +0800 Subject: [PATCH 063/115] test --- .github/workflows/build.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8bf3f975..59e31d56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,6 +53,10 @@ jobs: - name: Run Unit Tests shell: pwsh run: | + Write-Host "Current directory: $(Get-Location)" + $resultsDir = Join-Path (Get-Location) "TestResults" + Write-Host "Results directory will be: $resultsDir" + dotnet test AIDevGallery.Tests ` --no-build ` -r win-${{ matrix.dotnet-arch }} ` @@ -63,17 +67,25 @@ jobs: --logger "trx;LogFileName=UnitTestResults.trx" ` --logger "console;verbosity=detailed" ` --collect:"XPlat Code Coverage" ` - --results-directory TestResults ` + --results-directory "$resultsDir" ` -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover + + Write-Host "Test command exit code: $LASTEXITCODE" - name: List Test Results if: always() shell: pwsh run: | - Write-Host "Listing all files in TestResults directory:" - if (Test-Path TestResults) { - Get-ChildItem -Path TestResults -Recurse | ForEach-Object { Write-Host $_.FullName } + Write-Host "Current directory: $(Get-Location)" + Write-Host "Listing all files and directories:" + Get-ChildItem -Path . -Recurse -Filter "*.trx" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host "Found TRX: $($_.FullName)" } + + $resultsDir = Join-Path (Get-Location) "TestResults" + Write-Host "Checking for TestResults at: $resultsDir" + if (Test-Path $resultsDir) { + Write-Host "TestResults directory contents:" + Get-ChildItem -Path $resultsDir -Recurse | ForEach-Object { Write-Host " $($_.FullName)" } } else { - Write-Host "TestResults directory does not exist!" + Write-Host "TestResults directory does not exist at expected location!" } - name: Publish Test Results if: failure() From b9c4301773561df8cd1e6ba087ff1a10712905cd Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 18 Dec 2025 23:42:55 +0800 Subject: [PATCH 064/115] test --- .github/workflows/build.yml | 38 +++++++++---------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 59e31d56..48248fc5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,39 +53,19 @@ jobs: - name: Run Unit Tests shell: pwsh run: | - Write-Host "Current directory: $(Get-Location)" - $resultsDir = Join-Path (Get-Location) "TestResults" - Write-Host "Results directory will be: $resultsDir" - - dotnet test AIDevGallery.Tests ` - --no-build ` - -r win-${{ matrix.dotnet-arch }} ` - -f net9.0-windows10.0.26100.0 ` - /p:Configuration=${{ matrix.dotnet-configuration }} ` - /p:Platform=${{ matrix.dotnet-arch }} ` + New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\TestResults" | Out-Null + dotnet test AIDevGallery.Tests\bin\${{ matrix.dotnet-arch }}\${{ matrix.dotnet-configuration }}\net9.0-windows10.0.26100.0\win-${{ matrix.dotnet-arch }}\AIDevGallery.Tests.dll ` --filter "FullyQualifiedName~UnitTests" ` - --logger "trx;LogFileName=UnitTestResults.trx" ` + --logger "trx;LogFileName=${{ github.workspace }}\TestResults\UnitTestResults.trx" ` --logger "console;verbosity=detailed" ` --collect:"XPlat Code Coverage" ` - --results-directory "$resultsDir" ` + --results-directory "${{ github.workspace }}\TestResults" ` + --verbosity normal ` -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover - Write-Host "Test command exit code: $LASTEXITCODE" - - name: List Test Results - if: always() - shell: pwsh - run: | - Write-Host "Current directory: $(Get-Location)" - Write-Host "Listing all files and directories:" - Get-ChildItem -Path . -Recurse -Filter "*.trx" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host "Found TRX: $($_.FullName)" } - - $resultsDir = Join-Path (Get-Location) "TestResults" - Write-Host "Checking for TestResults at: $resultsDir" - if (Test-Path $resultsDir) { - Write-Host "TestResults directory contents:" - Get-ChildItem -Path $resultsDir -Recurse | ForEach-Object { Write-Host " $($_.FullName)" } - } else { - Write-Host "TestResults directory does not exist at expected location!" + if ($LASTEXITCODE -ne 0) { + Write-Error "Unit tests failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE } - name: Publish Test Results if: failure() @@ -100,7 +80,7 @@ jobs: uses: dorny/test-reporter@v2 with: name: Unit Test Report - ${{ matrix.dotnet-arch }} - path: '**/TestResults/*.trx' + path: 'TestResults/*.trx' reporter: dotnet-trx fail-on-error: false From 42938661dd3f8c04f94ead912d5c2a5b8b2ea423 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Fri, 19 Dec 2025 13:47:38 +0800 Subject: [PATCH 065/115] remove useless file --- .../ExcludeDuplicateResources.props | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 AIDevGallery.Tests/ExcludeDuplicateResources.props diff --git a/AIDevGallery.Tests/ExcludeDuplicateResources.props b/AIDevGallery.Tests/ExcludeDuplicateResources.props deleted file mode 100644 index 772c238d..00000000 --- a/AIDevGallery.Tests/ExcludeDuplicateResources.props +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - From 81ce59fca320558ae418f0a5911ad87e3035ea9b Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 11:22:26 +0800 Subject: [PATCH 066/115] Use AsyncLocal to isolate test performance metrics --- .../TestInfra/PerformanceCollector.cs | 60 +++++++------------ 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs index 4fc3ea0b..b02493ea 100644 --- a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs +++ b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.IO; using System.Text.Json; +using System.Threading; namespace AIDevGallery.Tests.TestInfra; @@ -80,14 +81,28 @@ public class Measurement public static class PerformanceCollector { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - private static readonly List _measurements = new(); + + // Use AsyncLocal to isolate measurements per test execution context + // This prevents data mixing when multiple tests run in parallel + private static readonly AsyncLocal> _measurements = new(); + private static readonly object _lock = new(); + private static List GetMeasurements() + { + if (_measurements.Value == null) + { + _measurements.Value = new List(); + } + return _measurements.Value; + } + public static void Track(string name, double value, string unit, Dictionary? tags = null, string category = "General") { lock (_lock) { - _measurements.Add(new Measurement + var measurements = GetMeasurements(); + measurements.Add(new Measurement { Category = category, Name = name, @@ -103,14 +118,14 @@ public static string Save(string? outputDirectory = null) List measurementsSnapshot; lock (_lock) { - measurementsSnapshot = new List(_measurements); + var measurements = GetMeasurements(); + measurementsSnapshot = new List(measurements); } var report = new PerformanceReport { Meta = new Metadata { - // Support both GitHub Actions and Azure Pipelines variables RunId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID") ?? Environment.GetEnvironmentVariable("BUILD_BUILDID") ?? "local-run", CommitHash = Environment.GetEnvironmentVariable("GITHUB_SHA") ?? Environment.GetEnvironmentVariable("BUILD_SOURCEVERSION") ?? "local-sha", Branch = Environment.GetEnvironmentVariable("GITHUB_REF_NAME") ?? Environment.GetEnvironmentVariable("BUILD_SOURCEBRANCHNAME") ?? "local-branch", @@ -133,7 +148,6 @@ public static string Save(string? outputDirectory = null) var json = JsonSerializer.Serialize(report, JsonOptions); - // Allow overriding output directory via environment variable (useful for CI) string? envOutputDir = Environment.GetEnvironmentVariable("PERFORMANCE_OUTPUT_PATH"); string dir = outputDirectory ?? envOutputDir ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "PerfResults"); @@ -155,26 +169,17 @@ public static void Clear() { lock (_lock) { - _measurements.Clear(); + var measurements = GetMeasurements(); + measurements.Clear(); } } - /// - /// Tracks memory usage for a specific process. - /// - /// The process ID to measure. - /// The name of the metric (e.g., "MemoryUsage_Startup"). - /// Optional tags for categorization. - /// The category for this metric (default: "Memory"). - /// True if successful, false if measurement failed. public static bool TrackMemoryUsage(int processId, string metricName, Dictionary? tags = null, string category = "Memory") { try { Console.WriteLine($"Attempting to measure memory for process ID: {processId}"); var process = Process.GetProcessById(processId); - - // Refresh process info to get latest memory values process.Refresh(); var memoryMB = process.PrivateMemorySize64 / 1024.0 / 1024.0; @@ -183,7 +188,6 @@ public static bool TrackMemoryUsage(int processId, string metricName, Dictionary Track(metricName, memoryMB, "MB", tags, category); Console.WriteLine($"{metricName}: {memoryMB:F2} MB (Private), {workingSetMB:F2} MB (Working Set)"); - // Also track working set as a separate metric Track($"{metricName}_WorkingSet", workingSetMB, "MB", tags, category); return true; @@ -198,26 +202,11 @@ public static bool TrackMemoryUsage(int processId, string metricName, Dictionary } } - /// - /// Tracks memory usage for the current process. - /// - /// The name of the metric (e.g., "MemoryUsage_Current"). - /// Optional tags for categorization. - /// The category for this metric (default: "Memory"). - /// True if successful, false if measurement failed. public static bool TrackCurrentProcessMemory(string metricName, Dictionary? tags = null, string category = "Memory") { return TrackMemoryUsage(Environment.ProcessId, metricName, tags, category); } - /// - /// Creates a timing scope that automatically tracks elapsed time when disposed. - /// Use with 'using' statement for automatic timing. - /// - /// The name of the metric to track. - /// Optional tags for categorization. - /// The category for this metric (default: "Timing"). - /// A disposable timing scope. public static IDisposable BeginTiming(string metricName, Dictionary? tags = null, string category = "Timing") { return new TimingScope(metricName, tags, category); @@ -252,28 +241,23 @@ private static HardwareInfo GetHardwareInfo() try { - // Basic CPU info from environment if WMI fails or on non-Windows info.Cpu = Environment.GetEnvironmentVariable("PROCESSOR_IDENTIFIER") ?? "Unknown CPU"; - // On Windows, we can try to get more details via WMI (System.Management) - // Note: This requires the System.Management NuGet package and Windows OS if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) { try { - // Simple memory check var gcMemoryInfo = GC.GetGCMemoryInfo(); long totalMemoryBytes = gcMemoryInfo.TotalAvailableMemoryBytes; info.Ram = $"{totalMemoryBytes / (1024 * 1024 * 1024)} GB"; } catch - { /* Ignore hardware detection errors */ + { } } } catch { - // Fallback defaults info.Cpu = "Unknown"; info.Ram = "Unknown"; } From 5b0326629beba9848cce7abccafede931b69c8a9 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 11:25:50 +0800 Subject: [PATCH 067/115] Clear measurements after save to prevent memory leak --- AIDevGallery.Tests/TestInfra/PerformanceCollector.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs index b02493ea..bee527ba 100644 --- a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs +++ b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs @@ -162,6 +162,8 @@ public static string Save(string? outputDirectory = null) File.WriteAllText(filePath, json); Console.WriteLine($"Performance metrics saved to: {filePath}"); + Clear(); + return filePath; } From 947b70e446e212ea4f47b9ba4317e66779b6c9fd Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 11:35:31 +0800 Subject: [PATCH 068/115] Add cleanup failure warnings and refactor MSIX deployment --- .github/scripts/deploy-msix.ps1 | 30 ++++++++++++++- .github/workflows/build.yml | 66 +++------------------------------ 2 files changed, 33 insertions(+), 63 deletions(-) diff --git a/.github/scripts/deploy-msix.ps1 b/.github/scripts/deploy-msix.ps1 index 9bd5e162..ef1215aa 100644 --- a/.github/scripts/deploy-msix.ps1 +++ b/.github/scripts/deploy-msix.ps1 @@ -47,8 +47,34 @@ Write-Host "" # Cleanup previous installations Write-Host "Cleaning up previous installations..." -Get-AppxPackage -Name "*AIDevGallery*" | Remove-AppxPackage -ErrorAction SilentlyContinue -Get-AppxPackage -Name "*e7af07c0*" | Remove-AppxPackage -ErrorAction SilentlyContinue +$cleanupFailed = $false + +$existingPackage = Get-AppxPackage -Name "*AIDevGallery*" +if ($existingPackage) { + try { + $existingPackage | Remove-AppxPackage + Write-Host "Removed AIDevGallery package(s)" + } catch { + Write-Warning "Failed to remove AIDevGallery package: $_" + $cleanupFailed = $true + } +} + +$existingPackage2 = Get-AppxPackage -Name "*e7af07c0*" +if ($existingPackage2) { + try { + $existingPackage2 | Remove-AppxPackage + Write-Host "Removed e7af07c0 package(s)" + } catch { + Write-Warning "Failed to remove e7af07c0 package: $_" + $cleanupFailed = $true + } +} + +if ($cleanupFailed) { + Write-Warning "Some cleanup operations failed. This may cause installation issues if conflicting packages remain." +} + Write-Host "Cleanup completed" Write-Host "" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f11c634..4f9bdc59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -219,71 +219,15 @@ jobs: Write-Error "Failed to download or setup Axe.Windows CLI: $_" exit 1 } - - name: Install and Run Accessibility Check + - name: Deploy MSIX Package + shell: pwsh + run: | + .\.github\scripts\deploy-msix.ps1 -MsixSearchPath ".\DownloadedMSIX" -IsWorkflowRun $true + - name: Launch App and Run Accessibility Tests shell: powershell run: | $ErrorActionPreference = "Stop" - # Find MSIX from downloaded artifact (search recursively) - $downloadPath = Join-Path (Get-Location) "DownloadedMSIX" - Write-Host "Looking for MSIX in: $downloadPath" - - $msixFile = Get-ChildItem -Path $downloadPath -Filter "*.msix" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $msixFile) { - Write-Error "MSIX file not found in $downloadPath (recursive search)" - exit 1 - } - - Write-Host "Using MSIX file: $($msixFile.FullName)" - - # Cleanup previous install - Get-AppxPackage -Name "*AIDevGallery*" | Remove-AppxPackage -ErrorAction SilentlyContinue - Get-AppxPackage -Name "*e7af07c0*" | Remove-AppxPackage -ErrorAction SilentlyContinue - - # Ensure makeappx.exe is available - if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { - Write-Host "makeappx.exe not found in PATH. Searching in Windows Kits..." - $kitsRoot = "C:\Program Files (x86)\Windows Kits\10\bin" - if (Test-Path $kitsRoot) { - $latestSdk = Get-ChildItem -Path $kitsRoot -Directory | - Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | - Sort-Object Name -Descending | - Select-Object -First 1 - - if ($latestSdk) { - $sdkPath = Join-Path $latestSdk.FullName "x64" - if (Test-Path $sdkPath) { - Write-Host "Found SDK at $sdkPath" - $env:Path = "$sdkPath;$env:Path" - } - } - - if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { - $genericSdkPath = Join-Path $kitsRoot "x64" - if (Test-Path $genericSdkPath) { - Write-Host "Found generic SDK at $genericSdkPath" - $env:Path = "$genericSdkPath;$env:Path" - } - } - } - } - - if (-not (Get-Command "makeappx.exe" -ErrorAction SilentlyContinue)) { - Write-Error "makeappx.exe could not be found. Please ensure Windows SDK is installed." - exit 1 - } - - # Unpack MSIX - $unpackedDir = Join-Path $PWD "AIDevGallery_Unpacked" - if (Test-Path $unpackedDir) { Remove-Item -Path $unpackedDir -Recurse -Force } - Write-Host "Unpacking MSIX to $unpackedDir..." - makeappx.exe unpack /p $msixFile.FullName /d $unpackedDir - - # Register Manifest - $manifestPath = Join-Path $unpackedDir "AppxManifest.xml" - Write-Host "Registering AppxManifest.xml from $manifestPath..." - Add-AppxPackage -Register $manifestPath -ForceUpdateFromAnyVersion - # Launch App $package = Get-AppxPackage -Name "*e7af07c0*" | Select-Object -First 1 if (-not $package) { From a752bad1777a05b30703f464e78d321ddbcf5ab6 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 11:40:48 +0800 Subject: [PATCH 069/115] ci: Include Directory.Packages.props in NuGet cache key --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f9bdc59..9042a179 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-${{ matrix.dotnet-arch }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }} + key: ${{ runner.os }}-${{ matrix.dotnet-arch }}-nuget-${{ hashFiles('**/packages.lock.json', 'Directory.Packages.props', '**/*.csproj') }} restore-keys: | ${{ runner.os }}-${{ matrix.dotnet-arch }}-nuget- - name: Restore dependencies From 0944d37c1a48a7365b5e31a12a474f547900a881 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 12:16:47 +0800 Subject: [PATCH 070/115] format --- AIDevGallery.Tests/TestInfra/PerformanceCollector.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs index bee527ba..404502c0 100644 --- a/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs +++ b/AIDevGallery.Tests/TestInfra/PerformanceCollector.cs @@ -81,11 +81,11 @@ public class Measurement public static class PerformanceCollector { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - + // Use AsyncLocal to isolate measurements per test execution context // This prevents data mixing when multiple tests run in parallel private static readonly AsyncLocal> _measurements = new(); - + private static readonly object _lock = new(); private static List GetMeasurements() @@ -94,6 +94,7 @@ private static List GetMeasurements() { _measurements.Value = new List(); } + return _measurements.Value; } From d91b38a080761cc4279864af371caae5f0431243 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 15:38:23 +0800 Subject: [PATCH 071/115] Replace Thread.Sleep with explicit waits in NavigationViewTests --- AIDevGallery.Tests/TestInfra/TestConfiguration.cs | 6 +++++- AIDevGallery.Tests/UITests/NavigationViewTests.cs | 14 ++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs index 122ac604..2cbce42c 100644 --- a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs +++ b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs @@ -10,6 +10,10 @@ public static class TestConfiguration { /// /// The MSIX package identity name GUID from Package.appxmanifest. + /// Can be overridden via environment variable TEST_PACKAGE_IDENTITY_NAME for local development. + /// Default: e7af07c0-77d2-43e5-ab82-9cdb9daa11b3 /// - public const string MsixPackageIdentityName = "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; + public static readonly string MsixPackageIdentityName = + Environment.GetEnvironmentVariable("TEST_PACKAGE_IDENTITY_NAME") + ?? "e7af07c0-77d2-43e5-ab82-9cdb9daa11b3"; } \ No newline at end of file diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index f815d125..4e9916e4 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -28,10 +28,11 @@ public void NavigationViewClickAllLeftMenuHostItems() Assert.IsNotNull(MainWindow, "Main window should be initialized"); Console.WriteLine("Starting test: Click all navigation items"); - Thread.Sleep(1000); - // Act - Find the MenuItemsHost to get only top-level navigation items - var menuItemsHost = MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHost")); + var menuItemsHostResult = Retry.WhileNull( + () => MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHost")), + timeout: TimeSpan.FromSeconds(10)); + var menuItemsHost = menuItemsHostResult.Result; Assert.IsNotNull(menuItemsHost, "MenuItemsHost should be found"); @@ -54,7 +55,12 @@ public void NavigationViewClickAllLeftMenuHostItems() try { item.Click(); - Thread.Sleep(1000); + + // Wait for navigation to complete (UI to stabilize) + Retry.WhileTrue( + () => MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHost")) == null, + timeout: TimeSpan.FromSeconds(5), + throwOnTimeout: false); var screenshotName = $"NavigationView_Item_{item.Name?.Replace(" ", "_") ?? "Unknown"}"; TakeScreenshot(screenshotName); From 240a2e439a6adb8ba0e666b18614bf27ed1fd685 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 15:40:48 +0800 Subject: [PATCH 072/115] remove comment --- .github/workflows/test-report.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index 44181715..bbfe7dc0 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -2,7 +2,6 @@ name: Regression Tests on: workflow_dispatch: - # TODO: Enable workflow_run trigger after validating the workflow works reliably # This would allow automatic testing after successful CI builds # workflow_run: # workflows: ["CI"] From dd5a143876868fd3550f8eb890e486d5e9710626 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Wed, 24 Dec 2025 23:30:13 +0800 Subject: [PATCH 073/115] Fix --- AIDevGallery.Tests/TestInfra/TestConfiguration.cs | 2 ++ AIDevGallery.Tests/UITests/NavigationViewTests.cs | 9 ++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs index 2cbce42c..18a31b86 100644 --- a/AIDevGallery.Tests/TestInfra/TestConfiguration.cs +++ b/AIDevGallery.Tests/TestInfra/TestConfiguration.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; + namespace AIDevGallery.Tests.TestInfra; /// diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index 4e9916e4..a43ea4a8 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -3,10 +3,10 @@ using AIDevGallery.Tests.TestInfra; using FlaUI.Core.Definitions; +using FlaUI.Core.Tools; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Linq; -using System.Threading; namespace AIDevGallery.Tests.UITests; @@ -55,12 +55,7 @@ public void NavigationViewClickAllLeftMenuHostItems() try { item.Click(); - - // Wait for navigation to complete (UI to stabilize) - Retry.WhileTrue( - () => MainWindow.FindFirstDescendant(cf => cf.ByAutomationId("MenuItemsHost")) == null, - timeout: TimeSpan.FromSeconds(5), - throwOnTimeout: false); + Wait.UntilResponsive(MainWindow, TimeSpan.FromSeconds(5)); var screenshotName = $"NavigationView_Item_{item.Name?.Replace(" ", "_") ?? "Unknown"}"; TakeScreenshot(screenshotName); From e257423468f95c58b2d7e7b3de223a2760a158bd Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 25 Dec 2025 00:13:34 +0800 Subject: [PATCH 074/115] update --- AIDevGallery.Tests/UITests/NavigationViewTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index a43ea4a8..6c4f3fe9 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -7,6 +7,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Linq; +using System.Threading; namespace AIDevGallery.Tests.UITests; @@ -55,7 +56,12 @@ public void NavigationViewClickAllLeftMenuHostItems() try { item.Click(); - Wait.UntilResponsive(MainWindow, TimeSpan.FromSeconds(5)); + + Retry.While( + () => !MainWindow.IsAvailable || MainWindow.IsOffscreen, + timeout: TimeSpan.FromSeconds(5), + throwOnTimeout: false, + timeoutMessage: "Window did not become responsive"); var screenshotName = $"NavigationView_Item_{item.Name?.Replace(" ", "_") ?? "Unknown"}"; TakeScreenshot(screenshotName); From b59608a3c2f7bd58fabd9a50c19c2a8204391714 Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 25 Dec 2025 00:21:33 +0800 Subject: [PATCH 075/115] update --- AIDevGallery.Tests/UITests/NavigationViewTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index 6c4f3fe9..ab3fc7cd 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -57,11 +57,11 @@ public void NavigationViewClickAllLeftMenuHostItems() { item.Click(); - Retry.While( + // Wait for window to become responsive after click + Retry.WhileTrue( () => !MainWindow.IsAvailable || MainWindow.IsOffscreen, timeout: TimeSpan.FromSeconds(5), - throwOnTimeout: false, - timeoutMessage: "Window did not become responsive"); + throwOnTimeout: false); var screenshotName = $"NavigationView_Item_{item.Name?.Replace(" ", "_") ?? "Unknown"}"; TakeScreenshot(screenshotName); From 5ac141629da99d79f16a80cc1182c3e2b613460c Mon Sep 17 00:00:00 2001 From: MillyWei Date: Thu, 25 Dec 2025 00:27:46 +0800 Subject: [PATCH 076/115] update --- AIDevGallery.Tests/UITests/NavigationViewTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/AIDevGallery.Tests/UITests/NavigationViewTests.cs b/AIDevGallery.Tests/UITests/NavigationViewTests.cs index ab3fc7cd..ef51ff02 100644 --- a/AIDevGallery.Tests/UITests/NavigationViewTests.cs +++ b/AIDevGallery.Tests/UITests/NavigationViewTests.cs @@ -7,7 +7,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Linq; -using System.Threading; namespace AIDevGallery.Tests.UITests; From b5858e15178195216621619337044a7b5f31c966 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 25 Dec 2025 14:53:37 +0800 Subject: [PATCH 077/115] Add empty stream detection with informative error message --- .../FoundryLocal/FoundryLocalChatClientAdapter.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index 98c28313..01a77292 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -80,8 +80,10 @@ public async IAsyncEnumerable GetStreamingResponseAsync( var streamingResponse = _chatClient.CompleteChatStreamingAsync(openAIMessages, cancellationToken); string responseId = Guid.NewGuid().ToString("N"); + int chunkCount = 0; await foreach (var chunk in streamingResponse) { + chunkCount++; cancellationToken.ThrowIfCancellationRequested(); if (chunk.Choices != null && chunk.Choices.Count > 0) @@ -96,6 +98,14 @@ public async IAsyncEnumerable GetStreamingResponseAsync( } } } + + if (chunkCount == 0) + { + var errorMessage = $"The model '{_modelId}' did not generate any output. " + + "Please verify you have selected an appropriate language model for text generation."; + + throw new InvalidOperationException(errorMessage); + } } public object? GetService(Type serviceType, object? serviceKey = null) From 0567fe703d93f5bd63d77425eec7cd07cfe3f4ee Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 25 Dec 2025 14:58:39 +0800 Subject: [PATCH 078/115] format --- .../FoundryLocal/FoundryLocalChatClientAdapter.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index 01a77292..d551182b 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -103,7 +103,6 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { var errorMessage = $"The model '{_modelId}' did not generate any output. " + "Please verify you have selected an appropriate language model for text generation."; - throw new InvalidOperationException(errorMessage); } } From 9d013a2649d4128a2d51fc3032006ffb94bddcf0 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 25 Dec 2025 15:11:31 +0800 Subject: [PATCH 079/115] format --- .../FoundryLocal/FoundryLocalChatClientAdapter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index d551182b..a2790ba7 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -102,7 +102,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( if (chunkCount == 0) { var errorMessage = $"The model '{_modelId}' did not generate any output. " + - "Please verify you have selected an appropriate language model for text generation."; + "Please verify you have selected an appropriate language model."; throw new InvalidOperationException(errorMessage); } } From fbccca69283e71601fd86918f4b63cb0ebbd65a0 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 25 Dec 2025 19:45:12 +0800 Subject: [PATCH 080/115] Add FL clean cache --- .../FoundryLocal/FoundryClient.cs | 31 ++++- .../FoundryLocalChatClientAdapter.cs | 6 +- .../FoundryLocalModelProvider.cs | 108 +++++++++++++++++- AIDevGallery/Models/CachedModel.cs | 8 +- AIDevGallery/Pages/SettingsPage.xaml.cs | 33 +++++- AIDevGallery/Utils/AppUtils.cs | 2 + 6 files changed, 179 insertions(+), 9 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index 59aa5a8e..ee9fe323 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using AIDevGallery.Utils; using Microsoft.AI.Foundry.Local; using Microsoft.Extensions.Logging.Abstractions; using System; @@ -14,6 +15,7 @@ namespace AIDevGallery.ExternalModelUtils.FoundryLocal; internal class FoundryClient : IDisposable { private readonly Dictionary _preparedModels = new(); + private readonly Dictionary _modelMaxOutputTokens = new(); private readonly SemaphoreSlim _prepareLock = new(1, 1); private FoundryLocalManager? _manager; private ICatalog? _catalog; @@ -25,7 +27,7 @@ internal class FoundryClient : IDisposable { var config = new Configuration { - AppName = "AIDevGallery", + AppName = AppUtils.AppName, LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Warning, Web = new Configuration.WebService { @@ -89,6 +91,7 @@ public async Task> ListCachedModels() } var cachedVariants = await _catalog.GetCachedModelsAsync(); + return cachedVariants.Select(variant => new FoundryCachedModelInfo(variant.Info.Name, variant.Alias)).ToList(); } @@ -169,8 +172,12 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation await model.LoadAsync(cancellationToken); } - // Store the model directly - no web service needed _preparedModels[alias] = model; + + if (!_modelMaxOutputTokens.ContainsKey(alias)) + { + _modelMaxOutputTokens[alias] = (int?)model.SelectedVariant.Info.MaxOutputTokens; + } } finally { @@ -188,6 +195,26 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation return _preparedModels.TryGetValue(alias, out var model) ? model : null; } + /// + /// Gets the MaxOutputTokens for a model by alias. + /// + /// The model alias. + /// The MaxOutputTokens value if found, otherwise null. + public int? GetModelMaxOutputTokens(string alias) + { + return _modelMaxOutputTokens.TryGetValue(alias, out var maxTokens) ? maxTokens : null; + } + + /// + /// Clears all prepared models and cached metadata from memory. + /// Should be called when models are deleted from cache. + /// + public void ClearPreparedModels() + { + _preparedModels.Clear(); + _modelMaxOutputTokens.Clear(); + } + public Task GetServiceUrl() { return Task.FromResult(_manager?.Urls?.FirstOrDefault()); diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index a2790ba7..573127ac 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -21,11 +21,13 @@ internal class FoundryLocalChatClientAdapter : IChatClient private readonly Microsoft.AI.Foundry.Local.OpenAIChatClient _chatClient; private readonly string _modelId; + private readonly int? _modelMaxOutputTokens; - public FoundryLocalChatClientAdapter(Microsoft.AI.Foundry.Local.OpenAIChatClient chatClient, string modelId) + public FoundryLocalChatClientAdapter(Microsoft.AI.Foundry.Local.OpenAIChatClient chatClient, string modelId, int? modelMaxOutputTokens = null) { _modelId = modelId; _chatClient = chatClient; + _modelMaxOutputTokens = modelMaxOutputTokens; } public ChatClientMetadata Metadata => new("FoundryLocal", new Uri($"foundrylocal:///{_modelId}"), _modelId); @@ -43,7 +45,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { // Map ChatOptions to FoundryLocal ChatSettings // CRITICAL: MaxTokens must be set, otherwise some model won't generate any output - _chatClient.Settings.MaxTokens = options?.MaxOutputTokens ?? DefaultMaxTokens; + _chatClient.Settings.MaxTokens = options?.MaxOutputTokens ?? _modelMaxOutputTokens ?? DefaultMaxTokens; if (options?.Temperature != null) { diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 32557677..65b58d66 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.AI; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -65,8 +66,11 @@ internal class FoundryLocalModelProvider : IExternalModelProvider // Note: This synchronous wrapper is safe here because the model is already prepared/loaded var chatClient = model.GetChatClientAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + // Get model's MaxOutputTokens if available + int? maxOutputTokens = _foundryManager.GetModelMaxOutputTokens(alias); + // Wrap it in our adapter to implement IChatClient interface - return new FoundryLocal.FoundryLocalChatClientAdapter(chatClient, model.Id); + return new FoundryLocal.FoundryLocalChatClientAdapter(chatClient, model.Id, maxOutputTokens); } public string? GetIChatClientString(string url) @@ -144,6 +148,9 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress + /// Gets the base cache directory path for FoundryLocal models. + /// + private string GetCacheBasePath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), $".{AppUtils.AppName}", "cache", "models", "Microsoft"); + } + + /// + /// Gets cached models with their file system details (path and size). + /// + /// A representing the asynchronous operation. + public async Task> GetCachedModelsWithDetails() + { + var result = new List(); + var basePath = GetCacheBasePath(); + + if (!Directory.Exists(basePath)) + { + return result; + } + + var models = await GetModelsAsync(); + + foreach (var modelDetails in models) + { + // Find directory that starts with the model name (actual directory has version suffix like -1, -2, etc.) + var matchingDir = Directory.GetDirectories(basePath) + .FirstOrDefault(dir => Path.GetFileName(dir).StartsWith(modelDetails.Name + "-", StringComparison.OrdinalIgnoreCase) || + Path.GetFileName(dir).Equals(modelDetails.Name, StringComparison.OrdinalIgnoreCase)); + + if (matchingDir != null) + { + var dirInfo = new DirectoryInfo(matchingDir); + long modelSize = dirInfo.EnumerateFiles("*", SearchOption.AllDirectories).Sum(fi => fi.Length); + + var cachedModel = new CachedModel(modelDetails, matchingDir, false, modelSize); + result.Add(cachedModel); + } + } + + return result; + } + + /// + /// Deletes a specific cached model directory. + /// + /// The path to the model directory to delete. + /// True if the model was successfully deleted; otherwise, false. + public bool DeleteCachedModel(string modelPath) + { + if (!Directory.Exists(modelPath)) + { + return false; + } + + try + { + Directory.Delete(modelPath, true); + + // Reset internal state after deleting a model + Reset(); + + return true; + } + catch + { + return false; + } + } + + /// + /// Clears all FoundryLocal cached models. + /// + /// True if the cache was successfully cleared; otherwise, false. + public bool ClearAllCache() + { + var basePath = GetCacheBasePath(); + + if (!Directory.Exists(basePath)) + { + return true; + } + + try + { + Directory.Delete(basePath, true); + + // Reset internal state after clearing cache + Reset(); + + return true; + } + catch + { + return false; + } + } } \ No newline at end of file diff --git a/AIDevGallery/Models/CachedModel.cs b/AIDevGallery/Models/CachedModel.cs index bc077955..a0eae513 100644 --- a/AIDevGallery/Models/CachedModel.cs +++ b/AIDevGallery/Models/CachedModel.cs @@ -30,6 +30,11 @@ public CachedModel(ModelDetails details, string path, bool isFile, long modelSiz Url = details.Url; Source = CachedModelSource.Local; } + else if (details.Url.StartsWith("fl://", StringComparison.OrdinalIgnoreCase)) + { + Url = details.Url; + Source = CachedModelSource.FoundryLocal; + } else { Url = new HuggingFaceUrl(details.Url).FullUrl; @@ -48,5 +53,6 @@ internal enum CachedModelSource { GitHub, HuggingFace, - Local + Local, + FoundryLocal } \ No newline at end of file diff --git a/AIDevGallery/Pages/SettingsPage.xaml.cs b/AIDevGallery/Pages/SettingsPage.xaml.cs index 7194ac96..91da636d 100644 --- a/AIDevGallery/Pages/SettingsPage.xaml.cs +++ b/AIDevGallery/Pages/SettingsPage.xaml.cs @@ -60,7 +60,7 @@ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) base.OnNavigatingFrom(e); } - private void GetStorageInfo() + private async void GetStorageInfo() { cachedModels.Clear(); @@ -75,7 +75,19 @@ private void GetStorageInfo() totalCacheSize += cachedModel.ModelSize; } - if (App.ModelCache.Models.Count > 0) + var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; + if (await foundryLocalProvider.IsAvailable()) + { + var foundryModels = await foundryLocalProvider.GetCachedModelsWithDetails(); + + foreach (var cachedModel in foundryModels) + { + cachedModels.Add(cachedModel); + totalCacheSize += cachedModel.ModelSize; + } + } + + if (App.ModelCache.Models.Count > 0 || cachedModels.Count > 0) { ModelsExpander.IsExpanded = true; } @@ -109,7 +121,17 @@ private async void DeleteModel_Click(object sender, RoutedEventArgs e) if (result == ContentDialogResult.Primary) { - await App.ModelCache.DeleteModelFromCache(model); + // Check if it's a FoundryLocal model + if (model.Source == CachedModelSource.FoundryLocal) + { + var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; + foundryLocalProvider.DeleteCachedModel(model.Path); + } + else + { + await App.ModelCache.DeleteModelFromCache(model); + } + GetStorageInfo(); } } @@ -171,6 +193,11 @@ private async void ClearCache_Click(object sender, RoutedEventArgs e) if (result == ContentDialogResult.Primary) { await App.ModelCache.ClearCache(); + + // Clear FoundryLocal cache + var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; + foundryLocalProvider.ClearAllCache(); + GetStorageInfo(); } } diff --git a/AIDevGallery/Utils/AppUtils.cs b/AIDevGallery/Utils/AppUtils.cs index 406a82db..66d39201 100644 --- a/AIDevGallery/Utils/AppUtils.cs +++ b/AIDevGallery/Utils/AppUtils.cs @@ -25,6 +25,8 @@ namespace AIDevGallery.Utils; internal static class AppUtils { + public const string AppName = "AIDevGallery"; + private static readonly Guid DXCORE_ADAPTER_ATTRIBUTE_D3D12_GENERIC_ML = new(0xb71b0d41, 0x1088, 0x422f, 0xa2, 0x7c, 0x2, 0x50, 0xb7, 0xd3, 0xa9, 0x88); private static bool? _hasNpu; From be62c3a4ad0dc0b4f709371a520a1cd1a7df2eb9 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 25 Dec 2025 20:02:30 +0800 Subject: [PATCH 081/115] update --- AIDevGallery/Pages/SettingsPage.xaml.cs | 22 +++------------------- AIDevGallery/Utils/ModelCache.cs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/AIDevGallery/Pages/SettingsPage.xaml.cs b/AIDevGallery/Pages/SettingsPage.xaml.cs index 91da636d..8d09460b 100644 --- a/AIDevGallery/Pages/SettingsPage.xaml.cs +++ b/AIDevGallery/Pages/SettingsPage.xaml.cs @@ -68,26 +68,15 @@ private async void GetStorageInfo() FolderPathTxt.Content = cacheFolderPath; long totalCacheSize = 0; + var allModels = await App.ModelCache.GetAllModelsAsync(); - foreach (var cachedModel in App.ModelCache.Models.Where(m => m.Path.StartsWith(cacheFolderPath, StringComparison.OrdinalIgnoreCase)).OrderBy(m => m.Details.Name)) + foreach (var cachedModel in allModels.OrderBy(m => m.Details.Name)) { cachedModels.Add(cachedModel); totalCacheSize += cachedModel.ModelSize; } - var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; - if (await foundryLocalProvider.IsAvailable()) - { - var foundryModels = await foundryLocalProvider.GetCachedModelsWithDetails(); - - foreach (var cachedModel in foundryModels) - { - cachedModels.Add(cachedModel); - totalCacheSize += cachedModel.ModelSize; - } - } - - if (App.ModelCache.Models.Count > 0 || cachedModels.Count > 0) + if (cachedModels.Count > 0) { ModelsExpander.IsExpanded = true; } @@ -193,11 +182,6 @@ private async void ClearCache_Click(object sender, RoutedEventArgs e) if (result == ContentDialogResult.Primary) { await App.ModelCache.ClearCache(); - - // Clear FoundryLocal cache - var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; - foundryLocalProvider.ClearAllCache(); - GetStorageInfo(); } } diff --git a/AIDevGallery/Utils/ModelCache.cs b/AIDevGallery/Utils/ModelCache.cs index da9a7f41..d9a71719 100644 --- a/AIDevGallery/Utils/ModelCache.cs +++ b/AIDevGallery/Utils/ModelCache.cs @@ -109,6 +109,24 @@ public async Task DeleteModelFromCache(CachedModel model) } } + /// + /// Gets all cached models including both local cache and FoundryLocal models. + /// + /// A list of all cached models. + public async Task> GetAllModelsAsync() + { + var allModels = new List(CacheStore.Models); + + var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; + if (await foundryLocalProvider.IsAvailable()) + { + var foundryModels = await foundryLocalProvider.GetCachedModelsWithDetails(); + allModels.AddRange(foundryModels); + } + + return allModels; + } + public async Task ClearCache() { ModelCacheDeletedEvent.Log(); @@ -116,6 +134,9 @@ public async Task ClearCache() var cacheDir = GetCacheFolder(); Directory.Delete(cacheDir, true); await CacheStore.ClearAsync(); + + var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; + foundryLocalProvider.ClearAllCache(); } public async Task MoveCache(string path, CancellationToken ct) From f7ef6f629cb555f5b227a2f8033542e4962e6590 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 25 Dec 2025 20:20:12 +0800 Subject: [PATCH 082/115] update --- AIDevGallery/Pages/SettingsPage.xaml.cs | 12 +----------- AIDevGallery/Utils/ModelCache.cs | 8 ++++++++ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/AIDevGallery/Pages/SettingsPage.xaml.cs b/AIDevGallery/Pages/SettingsPage.xaml.cs index 8d09460b..86e7ccd6 100644 --- a/AIDevGallery/Pages/SettingsPage.xaml.cs +++ b/AIDevGallery/Pages/SettingsPage.xaml.cs @@ -110,17 +110,7 @@ private async void DeleteModel_Click(object sender, RoutedEventArgs e) if (result == ContentDialogResult.Primary) { - // Check if it's a FoundryLocal model - if (model.Source == CachedModelSource.FoundryLocal) - { - var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; - foundryLocalProvider.DeleteCachedModel(model.Path); - } - else - { - await App.ModelCache.DeleteModelFromCache(model); - } - + await App.ModelCache.DeleteModelFromCache(model); GetStorageInfo(); } } diff --git a/AIDevGallery/Utils/ModelCache.cs b/AIDevGallery/Utils/ModelCache.cs index d9a71719..3ae7a078 100644 --- a/AIDevGallery/Utils/ModelCache.cs +++ b/AIDevGallery/Utils/ModelCache.cs @@ -91,6 +91,14 @@ public async Task DeleteModelFromCache(string url) public async Task DeleteModelFromCache(CachedModel model) { ModelDeletedEvent.Log(model.Url); + + if (model.Source == CachedModelSource.FoundryLocal) + { + var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; + foundryLocalProvider.DeleteCachedModel(model.Path); + return; + } + await CacheStore.RemoveModel(model); if (model.Url.StartsWith("local", System.StringComparison.OrdinalIgnoreCase)) From 0ec0332ffbabdcba3fe388cd906bd42ddd98621b Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 25 Dec 2025 23:55:19 +0800 Subject: [PATCH 083/115] update --- .../FoundryLocal/FoundryClient.cs | 78 ++++++++++++------- .../FoundryLocalModelProvider.cs | 60 +++++++------- AIDevGallery/Utils/ModelCache.cs | 8 +- 3 files changed, 78 insertions(+), 68 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index ee9fe323..4dbb3118 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -134,7 +134,7 @@ await model.DownloadAsync( } /// - /// Prepares a model for use by loading it (no web service needed). + /// Prepares a model for use by loading it. /// Should be called after download or when first accessing a cached model. /// Thread-safe: multiple concurrent calls for the same alias will only prepare once. /// @@ -149,7 +149,6 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation await _prepareLock.WaitAsync(cancellationToken); try { - // Double-check pattern for thread safety if (_preparedModels.ContainsKey(alias)) { return; @@ -160,24 +159,24 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation throw new InvalidOperationException("Foundry Local client not initialized"); } - // SDK automatically selects the best variant for the given alias var model = await _catalog.GetModelAsync(alias); if (model == null) { throw new InvalidOperationException($"Model with alias '{alias}' not found in catalog"); } + if (!await model.IsCachedAsync()) + { + throw new InvalidOperationException($"Model with alias '{alias}' is not cached. Please download it first."); + } + if (!await model.IsLoadedAsync()) { await model.LoadAsync(cancellationToken); } _preparedModels[alias] = model; - - if (!_modelMaxOutputTokens.ContainsKey(alias)) - { - _modelMaxOutputTokens[alias] = (int?)model.SelectedVariant.Info.MaxOutputTokens; - } + _modelMaxOutputTokens[alias] = (int?)model.SelectedVariant.Info.MaxOutputTokens; } finally { @@ -185,39 +184,65 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation } } - /// - /// Gets the prepared model. - /// Returns null if the model hasn't been prepared yet. - /// - /// The IModel instance, or null if not prepared. public IModel? GetPreparedModel(string alias) { return _preparedModels.TryGetValue(alias, out var model) ? model : null; } - /// - /// Gets the MaxOutputTokens for a model by alias. - /// - /// The model alias. - /// The MaxOutputTokens value if found, otherwise null. public int? GetModelMaxOutputTokens(string alias) { return _modelMaxOutputTokens.TryGetValue(alias, out var maxTokens) ? maxTokens : null; } - /// - /// Clears all prepared models and cached metadata from memory. - /// Should be called when models are deleted from cache. - /// + public async Task DeleteModelAsync(string modelId) + { + if (_catalog == null) + { + return false; + } + + try + { + var variant = await _catalog.GetModelVariantAsync(modelId); + if (variant == null) + { + return false; + } + + if (await variant.IsLoadedAsync()) + { + await variant.UnloadAsync(); + } + + if (await variant.IsCachedAsync()) + { + await variant.RemoveFromCacheAsync(); + } + + var alias = variant.Alias; + if (!string.IsNullOrEmpty(alias)) + { + _preparedModels.Remove(alias); + _modelMaxOutputTokens.Remove(alias); + } + + return true; + } + catch + { + return false; + } + } + public void ClearPreparedModels() { _preparedModels.Clear(); _modelMaxOutputTokens.Clear(); } - public Task GetServiceUrl() + public string? GetServiceUrl() { - return Task.FromResult(_manager?.Urls?.FirstOrDefault()); + return _manager?.Urls?.FirstOrDefault(); } public void Dispose() @@ -228,11 +253,6 @@ public void Dispose() } _prepareLock.Dispose(); - - // Models are managed by FoundryLocalManager and should not be disposed here - // The FoundryLocalManager instance is a singleton and manages its own lifecycle - _preparedModels.Clear(); - _disposed = true; } } \ No newline at end of file diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 65b58d66..1dfea004 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -139,7 +139,6 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress - /// Gets the base cache directory path for FoundryLocal models. - /// private string GetCacheBasePath() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), $".{AppUtils.AppName}", "cache", "models", "Microsoft"); } - /// - /// Gets cached models with their file system details (path and size). - /// - /// A representing the asynchronous operation. public async Task> GetCachedModelsWithDetails() { var result = new List(); @@ -265,7 +256,6 @@ public async Task> GetCachedModelsWithDetails() foreach (var modelDetails in models) { - // Find directory that starts with the model name (actual directory has version suffix like -1, -2, etc.) var matchingDir = Directory.GetDirectories(basePath) .FirstOrDefault(dir => Path.GetFileName(dir).StartsWith(modelDetails.Name + "-", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(dir).Equals(modelDetails.Name, StringComparison.OrdinalIgnoreCase)); @@ -283,26 +273,27 @@ public async Task> GetCachedModelsWithDetails() return result; } - /// - /// Deletes a specific cached model directory. - /// - /// The path to the model directory to delete. - /// True if the model was successfully deleted; otherwise, false. - public bool DeleteCachedModel(string modelPath) + public async Task DeleteCachedModelAsync(CachedModel cachedModel) { - if (!Directory.Exists(modelPath)) + if (_foundryManager == null) { return false; } try { - Directory.Delete(modelPath, true); + if (cachedModel.Details.ProviderModelDetails is FoundryCatalogModel catalogModel) + { + var result = await _foundryManager.DeleteModelAsync(catalogModel.ModelId); + if (result) + { + Reset(); + } - // Reset internal state after deleting a model - Reset(); + return result; + } - return true; + return false; } catch { @@ -310,27 +301,30 @@ public bool DeleteCachedModel(string modelPath) } } - /// - /// Clears all FoundryLocal cached models. - /// - /// True if the cache was successfully cleared; otherwise, false. - public bool ClearAllCache() + public async Task ClearAllCacheAsync() { - var basePath = GetCacheBasePath(); - - if (!Directory.Exists(basePath)) + if (_foundryManager == null) { return true; } try { - Directory.Delete(basePath, true); + var cachedModels = await GetCachedModelsWithDetails(); + var allDeleted = true; + + foreach (var cachedModel in cachedModels) + { + var deleted = await DeleteCachedModelAsync(cachedModel); + if (!deleted) + { + allDeleted = false; + } + } - // Reset internal state after clearing cache Reset(); - return true; + return allDeleted; } catch { diff --git a/AIDevGallery/Utils/ModelCache.cs b/AIDevGallery/Utils/ModelCache.cs index 3ae7a078..02f9bdc4 100644 --- a/AIDevGallery/Utils/ModelCache.cs +++ b/AIDevGallery/Utils/ModelCache.cs @@ -95,7 +95,7 @@ public async Task DeleteModelFromCache(CachedModel model) if (model.Source == CachedModelSource.FoundryLocal) { var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; - foundryLocalProvider.DeleteCachedModel(model.Path); + await foundryLocalProvider.DeleteCachedModelAsync(model); return; } @@ -117,10 +117,6 @@ public async Task DeleteModelFromCache(CachedModel model) } } - /// - /// Gets all cached models including both local cache and FoundryLocal models. - /// - /// A list of all cached models. public async Task> GetAllModelsAsync() { var allModels = new List(CacheStore.Models); @@ -144,7 +140,7 @@ public async Task ClearCache() await CacheStore.ClearAsync(); var foundryLocalProvider = ExternalModelUtils.FoundryLocalModelProvider.Instance; - foundryLocalProvider.ClearAllCache(); + await foundryLocalProvider.ClearAllCacheAsync(); } public async Task MoveCache(string path, CancellationToken ct) From dfe7ee5698a2bf3d39c92c13a73319d911658146 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 26 Dec 2025 00:21:07 +0800 Subject: [PATCH 084/115] update --- .../FoundryLocalModelProvider.cs | 31 ++++++------------- AIDevGallery/Pages/SettingsPage.xaml.cs | 6 ++++ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 1dfea004..2ec21d17 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -237,37 +237,26 @@ public async Task EnsureModelReadyAsync(string url, CancellationToken cancellati await _foundryManager.PrepareModelAsync(alias, cancellationToken); } - private string GetCacheBasePath() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), $".{AppUtils.AppName}", "cache", "models", "Microsoft"); - } - public async Task> GetCachedModelsWithDetails() { var result = new List(); - var basePath = GetCacheBasePath(); - - if (!Directory.Exists(basePath)) - { - return result; - } + // Get the list of downloaded models (which are already filtered by cached status) var models = await GetModelsAsync(); foreach (var modelDetails in models) { - var matchingDir = Directory.GetDirectories(basePath) - .FirstOrDefault(dir => Path.GetFileName(dir).StartsWith(modelDetails.Name + "-", StringComparison.OrdinalIgnoreCase) || - Path.GetFileName(dir).Equals(modelDetails.Name, StringComparison.OrdinalIgnoreCase)); - - if (matchingDir != null) + if (modelDetails.ProviderModelDetails is not FoundryCatalogModel catalogModel) { - var dirInfo = new DirectoryInfo(matchingDir); - long modelSize = dirInfo.EnumerateFiles("*", SearchOption.AllDirectories).Sum(fi => fi.Length); - - var cachedModel = new CachedModel(modelDetails, matchingDir, false, modelSize); - result.Add(cachedModel); + continue; } + + var cachedModel = new CachedModel( + modelDetails, + $"FoundryLocal: {catalogModel.Alias}", + false, + modelDetails.Size); + result.Add(cachedModel); } return result; diff --git a/AIDevGallery/Pages/SettingsPage.xaml.cs b/AIDevGallery/Pages/SettingsPage.xaml.cs index 86e7ccd6..1a80fe4d 100644 --- a/AIDevGallery/Pages/SettingsPage.xaml.cs +++ b/AIDevGallery/Pages/SettingsPage.xaml.cs @@ -185,6 +185,12 @@ private void ModelFolder_Click(object sender, RoutedEventArgs e) { if (sender is HyperlinkButton hyperlinkButton && hyperlinkButton.Tag is CachedModel model) { + // FoundryLocal models are managed by the SDK, don't open folder + if (model.Source == CachedModelSource.FoundryLocal) + { + return; + } + string? path = model.Path; if (model.IsFile) From d68faeff9c7eca732d2e143d177f6473135d227b Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 26 Dec 2025 00:26:40 +0800 Subject: [PATCH 085/115] update --- AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs | 3 +-- AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index 4dbb3118..eb2d5142 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using AIDevGallery.Utils; using Microsoft.AI.Foundry.Local; using Microsoft.Extensions.Logging.Abstractions; using System; @@ -27,7 +26,7 @@ internal class FoundryClient : IDisposable { var config = new Configuration { - AppName = AppUtils.AppName, + AppName = "AIDevGallery", LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Warning, Web = new Configuration.WebService { diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 2ec21d17..f46d98da 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.AI; using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; From f4ac4790c2318c1f5813a93e9916c43902e17d9a Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 26 Dec 2025 01:06:33 +0800 Subject: [PATCH 086/115] update --- .../FoundryLocal/FoundryClient.cs | 50 ++++++++++++++++--- .../FoundryLocalModelProvider.cs | 35 +++++++++---- .../Events/FoundryLocalErrorEvent.cs | 46 +++++++++++++++++ AIDevGallery/Utils/AppUtils.cs | 2 - 4 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index eb2d5142..b729fd43 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -140,14 +140,10 @@ await model.DownloadAsync( /// A representing the asynchronous operation. public async Task PrepareModelAsync(string alias, CancellationToken cancellationToken = default) { - if (_preparedModels.ContainsKey(alias)) - { - return; - } - await _prepareLock.WaitAsync(cancellationToken); try { + // Double-check inside lock to ensure thread safety if (_preparedModels.ContainsKey(alias)) { return; @@ -227,14 +223,33 @@ public async Task DeleteModelAsync(string modelId) return true; } - catch + catch (Exception ex) { + Telemetry.Events.FoundryLocalErrorEvent.Log("DeleteModelFailed", modelId, ex.Message); return false; } } - public void ClearPreparedModels() + public async Task ClearPreparedModelsAsync() { + // Unload all prepared models before clearing + foreach (var kvp in _preparedModels) + { + try + { + var model = kvp.Value; + if (await model.IsLoadedAsync()) + { + await model.UnloadAsync(); + } + } + catch (Exception ex) + { + // Log but continue unloading other models + Telemetry.Events.FoundryLocalErrorEvent.Log("UnloadModelFailed", kvp.Key, ex.Message); + } + } + _preparedModels.Clear(); _modelMaxOutputTokens.Clear(); } @@ -251,6 +266,27 @@ public void Dispose() return; } + // Unload all prepared models synchronously + foreach (var kvp in _preparedModels) + { + try + { + var model = kvp.Value; + + // Note: Using synchronous Wait here as Dispose cannot be async + if (model.IsLoadedAsync().ConfigureAwait(false).GetAwaiter().GetResult()) + { + model.UnloadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + } + catch + { + // Suppress exceptions during disposal to prevent finalizer issues + } + } + + _preparedModels.Clear(); + _modelMaxOutputTokens.Clear(); _prepareLock.Dispose(); _disposed = true; } diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index f46d98da..81541632 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -58,12 +58,18 @@ internal class FoundryLocalModelProvider : IExternalModelProvider if (model == null) { throw new InvalidOperationException( - $"Model '{alias}' is not ready yet. The model is being loaded in the background. Please wait a moment and try again."); + $"Model '{alias}' is not ready yet. The model is being loaded in the background. Please call EnsureModelReadyAsync(url) first."); } // Get the native FoundryLocal chat client - direct SDK usage, no web service needed - // Note: This synchronous wrapper is safe here because the model is already prepared/loaded - var chatClient = model.GetChatClientAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + // SAFETY: This synchronous wrapper is safe because: + // 1. The model is already prepared/loaded (checked above) + // 2. GetChatClientAsync on a loaded model should complete synchronously + // 3. ConfigureAwait(false) prevents SynchronizationContext capture + var chatClient = Task.Run(async () => await model.GetChatClientAsync().ConfigureAwait(false)) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); // Get model's MaxOutputTokens if available int? maxOutputTokens = _foundryManager.GetModelMaxOutputTokens(alias); @@ -111,7 +117,7 @@ public async Task> GetModelsAsync(bool ignoreCached = { if (ignoreCached) { - Reset(); + await ResetAsync(); } await InitializeAsync(cancelationToken); @@ -143,11 +149,18 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress + /// Resets the provider state by clearing downloaded models cache and unloading all prepared models. + /// WARNING: This will unload all currently loaded models. Any ongoing inference will fail. + /// + private async Task ResetAsync() { _downloadedModels = null; - _foundryManager?.ClearPreparedModels(); + if (_foundryManager != null) + { + await _foundryManager.ClearPreparedModelsAsync(); + } } private async Task InitializeAsync(CancellationToken cancelationToken = default) @@ -275,7 +288,7 @@ public async Task DeleteCachedModelAsync(CachedModel cachedModel) var result = await _foundryManager.DeleteModelAsync(catalogModel.ModelId); if (result) { - Reset(); + await ResetAsync(); } return result; @@ -283,8 +296,9 @@ public async Task DeleteCachedModelAsync(CachedModel cachedModel) return false; } - catch + catch (Exception ex) { + Telemetry.Events.FoundryLocalErrorEvent.Log("DeleteCachedModelFailed", cachedModel.Details.Name, ex.Message); return false; } } @@ -310,12 +324,13 @@ public async Task ClearAllCacheAsync() } } - Reset(); + await ResetAsync(); return allDeleted; } - catch + catch (Exception ex) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ClearAllCacheFailed", "all", ex.Message); return false; } } diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs b/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs new file mode 100644 index 00000000..f143cf5a --- /dev/null +++ b/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Diagnostics.Telemetry; +using Microsoft.Diagnostics.Telemetry.Internal; +using System; +using System.Diagnostics.Tracing; + +namespace AIDevGallery.Telemetry.Events; + +[EventData] +internal class FoundryLocalErrorEvent : EventBase +{ + internal FoundryLocalErrorEvent(string operation, string modelIdentifier, string errorMessage, DateTime eventTime) + { + Operation = operation; + ModelIdentifier = modelIdentifier; + ErrorMessage = errorMessage ?? string.Empty; + EventTime = eventTime; + } + + public string Operation { get; private set; } + + public string ModelIdentifier { get; private set; } + + public string ErrorMessage { get; private set; } + + public DateTime EventTime { get; private set; } + + public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + } + + public static void Log(string operation, string modelIdentifier, string errorMessage) + { + var relatedActivityId = Guid.NewGuid(); + TelemetryFactory.Get().LogError( + "FoundryLocalError_Event", + LogLevel.Critical, + new FoundryLocalErrorEvent(operation, modelIdentifier, errorMessage, DateTime.Now), + relatedActivityId); + } +} + diff --git a/AIDevGallery/Utils/AppUtils.cs b/AIDevGallery/Utils/AppUtils.cs index 66d39201..406a82db 100644 --- a/AIDevGallery/Utils/AppUtils.cs +++ b/AIDevGallery/Utils/AppUtils.cs @@ -25,8 +25,6 @@ namespace AIDevGallery.Utils; internal static class AppUtils { - public const string AppName = "AIDevGallery"; - private static readonly Guid DXCORE_ADAPTER_ATTRIBUTE_D3D12_GENERIC_ML = new(0xb71b0d41, 0x1088, 0x422f, 0xa2, 0x7c, 0x2, 0x50, 0xb7, 0xd3, 0xa9, 0x88); private static bool? _hasNpu; From f74f01d197608c984e75b5ce1a2675f9bb5a269c Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 26 Dec 2025 10:36:16 +0800 Subject: [PATCH 087/115] format --- AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs b/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs index f143cf5a..75de5ccb 100644 --- a/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs +++ b/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs @@ -42,5 +42,4 @@ public static void Log(string operation, string modelIdentifier, string errorMes new FoundryLocalErrorEvent(operation, modelIdentifier, errorMessage, DateTime.Now), relatedActivityId); } -} - +} \ No newline at end of file From d4d105b5c2ae843cba091da9c78e1876a9b1db7f Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 26 Dec 2025 11:23:31 +0800 Subject: [PATCH 088/115] Add Telemetry --- .../FoundryLocal/FoundryClient.cs | 68 ++++--- .../FoundryLocalChatClientAdapter.cs | 1 + .../FoundryLocalModelProvider.cs | 65 ++++++- .../Events/FoundryLocalDownloadEvent.cs | 55 ------ .../Events/FoundryLocalErrorEvent.cs | 45 ----- .../Telemetry/Events/FoundryLocalEvents.cs | 172 ++++++++++++++++++ 6 files changed, 276 insertions(+), 130 deletions(-) delete mode 100644 AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs delete mode 100644 AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs create mode 100644 AIDevGallery/Telemetry/Events/FoundryLocalEvents.cs diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs index b729fd43..a7ff773f 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryClient.cs @@ -38,6 +38,7 @@ internal class FoundryClient : IDisposable if (!FoundryLocalManager.IsInitialized) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ClientInitialization", "ManagerCreation", "N/A", "FoundryLocalManager failed to initialize"); return null; } @@ -49,10 +50,12 @@ internal class FoundryClient : IDisposable await client._manager.EnsureEpsDownloadedAsync(); client._catalog = await client._manager.GetCatalogAsync(); + Telemetry.Events.FoundryLocalOperationEvent.Log("ClientInitialization", "N/A"); return client; } - catch + catch (Exception ex) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ClientInitialization", "Exception", "N/A", ex.Message); return null; } } @@ -61,6 +64,7 @@ public async Task> ListCatalogModels() { if (_catalog == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ListCatalogModels", "CatalogNotInitialized", "N/A", "Catalog not initialized"); return []; } @@ -86,6 +90,7 @@ public async Task> ListCachedModels() { if (_catalog == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ListCachedModels", "CatalogNotInitialized", "N/A", "Catalog not initialized"); return []; } @@ -101,11 +106,13 @@ public async Task DownloadModel(FoundryCatalogModel catal return new FoundryDownloadResult(false, "Catalog not initialized"); } + var startTime = DateTime.Now; try { var model = await _catalog.GetModelAsync(catalogModel.Alias); if (model == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDownload", "ModelNotFound", catalogModel.Alias, "Model not found in catalog"); return new FoundryDownloadResult(false, "Model not found in catalog"); } @@ -124,10 +131,15 @@ await model.DownloadAsync( await PrepareModelAsync(catalogModel.Alias, cancellationToken); + var duration = (DateTime.Now - startTime).TotalSeconds; + Telemetry.Events.FoundryLocalOperationEvent.Log("ModelDownload", catalogModel.Alias, duration); + return new FoundryDownloadResult(true, null); } catch (Exception e) { + var duration = (DateTime.Now - startTime).TotalSeconds; + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDownload", "Exception", catalogModel.Alias, e.Message); return new FoundryDownloadResult(false, e.Message); } } @@ -140,6 +152,12 @@ await model.DownloadAsync( /// A representing the asynchronous operation. public async Task PrepareModelAsync(string alias, CancellationToken cancellationToken = default) { + if (_preparedModels.ContainsKey(alias)) + { + return; + } + + var startTime = DateTime.Now; await _prepareLock.WaitAsync(cancellationToken); try { @@ -151,17 +169,20 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation if (_catalog == null || _manager == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelPrepare", "ClientNotInitialized", alias, "Foundry Local client not initialized"); throw new InvalidOperationException("Foundry Local client not initialized"); } var model = await _catalog.GetModelAsync(alias); if (model == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelPrepare", "ModelNotFound", alias, $"Model with alias '{alias}' not found in catalog"); throw new InvalidOperationException($"Model with alias '{alias}' not found in catalog"); } if (!await model.IsCachedAsync()) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelPrepare", "ModelNotCached", alias, $"Model with alias '{alias}' is not cached"); throw new InvalidOperationException($"Model with alias '{alias}' is not cached. Please download it first."); } @@ -172,6 +193,15 @@ public async Task PrepareModelAsync(string alias, CancellationToken cancellation _preparedModels[alias] = model; _modelMaxOutputTokens[alias] = (int?)model.SelectedVariant.Info.MaxOutputTokens; + + var duration = (DateTime.Now - startTime).TotalSeconds; + Telemetry.Events.FoundryLocalOperationEvent.Log("ModelPrepare", alias, duration); + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + var duration = (DateTime.Now - startTime).TotalSeconds; + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelPrepare", "Exception", alias, ex.Message); + throw; } finally { @@ -193,6 +223,7 @@ public async Task DeleteModelAsync(string modelId) { if (_catalog == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDelete", "CatalogNotInitialized", modelId, "Catalog not initialized"); return false; } @@ -201,9 +232,12 @@ public async Task DeleteModelAsync(string modelId) var variant = await _catalog.GetModelVariantAsync(modelId); if (variant == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDelete", "VariantNotFound", modelId, "Model variant not found"); return false; } + var alias = variant.Alias; + if (await variant.IsLoadedAsync()) { await variant.UnloadAsync(); @@ -214,24 +248,26 @@ public async Task DeleteModelAsync(string modelId) await variant.RemoveFromCacheAsync(); } - var alias = variant.Alias; if (!string.IsNullOrEmpty(alias)) { _preparedModels.Remove(alias); _modelMaxOutputTokens.Remove(alias); } + Telemetry.Events.FoundryLocalOperationEvent.Log("ModelDelete", alias ?? modelId); return true; } catch (Exception ex) { - Telemetry.Events.FoundryLocalErrorEvent.Log("DeleteModelFailed", modelId, ex.Message); + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelDelete", "Exception", modelId, ex.Message); return false; } } public async Task ClearPreparedModelsAsync() { + var modelCount = _preparedModels.Count; + // Unload all prepared models before clearing foreach (var kvp in _preparedModels) { @@ -246,12 +282,17 @@ public async Task ClearPreparedModelsAsync() catch (Exception ex) { // Log but continue unloading other models - Telemetry.Events.FoundryLocalErrorEvent.Log("UnloadModelFailed", kvp.Key, ex.Message); + Telemetry.Events.FoundryLocalErrorEvent.Log("ModelUnload", "Exception", kvp.Key, ex.Message); } } _preparedModels.Clear(); _modelMaxOutputTokens.Clear(); + + if (modelCount > 0) + { + Telemetry.Events.FoundryLocalOperationEvent.Log("ClearPreparedModels", $"{modelCount} models"); + } } public string? GetServiceUrl() @@ -266,25 +307,6 @@ public void Dispose() return; } - // Unload all prepared models synchronously - foreach (var kvp in _preparedModels) - { - try - { - var model = kvp.Value; - - // Note: Using synchronous Wait here as Dispose cannot be async - if (model.IsLoadedAsync().ConfigureAwait(false).GetAwaiter().GetResult()) - { - model.UnloadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - } - } - catch - { - // Suppress exceptions during disposal to prevent finalizer issues - } - } - _preparedModels.Clear(); _modelMaxOutputTokens.Clear(); _prepareLock.Dispose(); diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs index 573127ac..66b086d8 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocal/FoundryLocalChatClientAdapter.cs @@ -105,6 +105,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { var errorMessage = $"The model '{_modelId}' did not generate any output. " + "Please verify you have selected an appropriate language model."; + Telemetry.Events.FoundryLocalErrorEvent.Log("ChatStreaming", "NoOutput", _modelId, errorMessage); throw new InvalidOperationException(errorMessage); } } diff --git a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs index 81541632..caeafb03 100644 --- a/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs +++ b/AIDevGallery/ExternalModelUtils/FoundryLocalModelProvider.cs @@ -50,6 +50,7 @@ internal class FoundryLocalModelProvider : IExternalModelProvider if (_foundryManager == null || string.IsNullOrEmpty(alias)) { + Telemetry.Events.FoundryLocalErrorEvent.Log("GetChatClient", "ClientNotInitialized", alias ?? "unknown", "Foundry Local client not initialized or invalid model alias"); throw new InvalidOperationException("Foundry Local client not initialized or invalid model alias"); } @@ -57,6 +58,7 @@ internal class FoundryLocalModelProvider : IExternalModelProvider var model = _foundryManager.GetPreparedModel(alias); if (model == null) { + Telemetry.Events.FoundryLocalErrorEvent.Log("GetChatClient", "ModelNotReady", alias, "Model is not ready yet. EnsureModelReadyAsync must be called first"); throw new InvalidOperationException( $"Model '{alias}' is not ready yet. The model is being loaded in the background. Please call EnsureModelReadyAsync(url) first."); } @@ -75,6 +77,7 @@ internal class FoundryLocalModelProvider : IExternalModelProvider int? maxOutputTokens = _foundryManager.GetModelMaxOutputTokens(alias); // Wrap it in our adapter to implement IChatClient interface + Telemetry.Events.FoundryLocalOperationEvent.Log("GetChatClient", alias); return new FoundryLocal.FoundryLocalChatClientAdapter(chatClient, model.Id, maxOutputTokens); } @@ -142,9 +145,16 @@ public async Task DownloadModel(ModelDetails modelDetails, IProgress DeleteCachedModelAsync(CachedModel cachedModel) var result = await _foundryManager.DeleteModelAsync(catalogModel.ModelId); if (result) { - await ResetAsync(); + if (_downloadedModels != null) + { + _downloadedModels = _downloadedModels.Where(m => + (m.ProviderModelDetails as FoundryCatalogModel)?.Alias != catalogModel.Alias); + } } return result; @@ -298,7 +314,11 @@ public async Task DeleteCachedModelAsync(CachedModel cachedModel) } catch (Exception ex) { - Telemetry.Events.FoundryLocalErrorEvent.Log("DeleteCachedModelFailed", cachedModel.Details.Name, ex.Message); + Telemetry.Events.FoundryLocalErrorEvent.Log( + "CachedModelDelete", + "Exception", + cachedModel.Details.Name, + ex.Message); return false; } } @@ -312,25 +332,56 @@ public async Task ClearAllCacheAsync() try { - var cachedModels = await GetCachedModelsWithDetails(); + // Get snapshot of cached models to avoid collection modification during enumeration + var cachedModels = (await GetCachedModelsWithDetails()).ToList(); var allDeleted = true; + var deletedCount = 0; foreach (var cachedModel in cachedModels) { - var deleted = await DeleteCachedModelAsync(cachedModel); - if (!deleted) + if (cachedModel.Details.ProviderModelDetails is not FoundryCatalogModel catalogModel) + { + continue; + } + + try { + var deleted = await _foundryManager.DeleteModelAsync(catalogModel.ModelId); + if (deleted) + { + deletedCount++; + } + else + { + allDeleted = false; + } + } + catch (Exception ex) + { + Telemetry.Events.FoundryLocalErrorEvent.Log( + "ClearAllCache", + "ModelDeletion", + catalogModel.Alias, + ex.Message); allDeleted = false; } } await ResetAsync(); + Telemetry.Events.FoundryLocalOperationEvent.Log( + "ClearAllCache", + $"{deletedCount}/{cachedModels.Count} models deleted"); + return allDeleted; } catch (Exception ex) { - Telemetry.Events.FoundryLocalErrorEvent.Log("ClearAllCacheFailed", "all", ex.Message); + Telemetry.Events.FoundryLocalErrorEvent.Log( + "ClearAllCache", + "Exception", + "all", + ex.Message); return false; } } diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs b/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs deleted file mode 100644 index 457ada06..00000000 --- a/AIDevGallery/Telemetry/Events/FoundryLocalDownloadEvent.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Diagnostics.Telemetry; -using Microsoft.Diagnostics.Telemetry.Internal; -using System; -using System.Diagnostics.Tracing; - -namespace AIDevGallery.Telemetry.Events; - -[EventData] -internal class FoundryLocalDownloadEvent : EventBase -{ - internal FoundryLocalDownloadEvent(string modelAlias, bool success, string? errorMessage, DateTime eventTime) - { - ModelAlias = modelAlias; - Success = success; - ErrorMessage = errorMessage ?? string.Empty; - EventTime = eventTime; - } - - public string ModelAlias { get; private set; } - - public bool Success { get; private set; } - - public string ErrorMessage { get; private set; } - - public DateTime EventTime { get; private set; } - - public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; - - public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) - { - } - - public static void Log(string modelAlias, bool success, string? errorMessage = null) - { - if (success) - { - TelemetryFactory.Get().Log( - "FoundryLocalDownload_Event", - LogLevel.Info, - new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, DateTime.Now)); - } - else - { - var relatedActivityId = Guid.NewGuid(); - TelemetryFactory.Get().LogError( - "FoundryLocalDownload_Event", - LogLevel.Critical, - new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, DateTime.Now), - relatedActivityId); - } - } -} \ No newline at end of file diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs b/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs deleted file mode 100644 index 75de5ccb..00000000 --- a/AIDevGallery/Telemetry/Events/FoundryLocalErrorEvent.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Diagnostics.Telemetry; -using Microsoft.Diagnostics.Telemetry.Internal; -using System; -using System.Diagnostics.Tracing; - -namespace AIDevGallery.Telemetry.Events; - -[EventData] -internal class FoundryLocalErrorEvent : EventBase -{ - internal FoundryLocalErrorEvent(string operation, string modelIdentifier, string errorMessage, DateTime eventTime) - { - Operation = operation; - ModelIdentifier = modelIdentifier; - ErrorMessage = errorMessage ?? string.Empty; - EventTime = eventTime; - } - - public string Operation { get; private set; } - - public string ModelIdentifier { get; private set; } - - public string ErrorMessage { get; private set; } - - public DateTime EventTime { get; private set; } - - public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; - - public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) - { - } - - public static void Log(string operation, string modelIdentifier, string errorMessage) - { - var relatedActivityId = Guid.NewGuid(); - TelemetryFactory.Get().LogError( - "FoundryLocalError_Event", - LogLevel.Critical, - new FoundryLocalErrorEvent(operation, modelIdentifier, errorMessage, DateTime.Now), - relatedActivityId); - } -} \ No newline at end of file diff --git a/AIDevGallery/Telemetry/Events/FoundryLocalEvents.cs b/AIDevGallery/Telemetry/Events/FoundryLocalEvents.cs new file mode 100644 index 00000000..ea3aadf6 --- /dev/null +++ b/AIDevGallery/Telemetry/Events/FoundryLocalEvents.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Diagnostics.Telemetry; +using Microsoft.Diagnostics.Telemetry.Internal; +using System; +using System.Diagnostics.Tracing; + +namespace AIDevGallery.Telemetry.Events; + +/// +/// Telemetry event for successful Foundry Local operations. +/// Use this for tracking successful operations like model preparation, loading, etc. +/// +[EventData] +internal class FoundryLocalOperationEvent : EventBase +{ + internal FoundryLocalOperationEvent( + string operation, + string modelIdentifier, + double? durationSeconds, + DateTime eventTime) + { + Operation = operation; + ModelIdentifier = modelIdentifier; + DurationSeconds = durationSeconds ?? 0; + EventTime = eventTime; + } + + public string Operation { get; private set; } + + public string ModelIdentifier { get; private set; } + + public double DurationSeconds { get; private set; } + + public DateTime EventTime { get; private set; } + + public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + } + + /// + /// Logs a successful Foundry Local operation. + /// + /// Operation name in PascalCase (e.g., "ModelPrepare", "ModelLoad", "ModelDelete") + /// Model alias or ID + /// Optional duration in seconds + public static void Log(string operation, string modelIdentifier, double? durationSeconds = null) + { + TelemetryFactory.Get().Log( + "FoundryLocalOperation_Event", + LogLevel.Info, + new FoundryLocalOperationEvent(operation, modelIdentifier, durationSeconds, DateTime.Now)); + } +} + +/// +/// Telemetry event for Foundry Local model download operations. +/// Tracks download success/failure, file size, and duration. +/// +[EventData] +internal class FoundryLocalDownloadEvent : EventBase +{ + internal FoundryLocalDownloadEvent( + string modelAlias, + bool success, + string? errorMessage, + long? fileSizeMb, + double? durationSeconds, + DateTime eventTime) + { + ModelAlias = modelAlias; + Success = success; + ErrorMessage = errorMessage ?? string.Empty; + FileSizeMb = fileSizeMb ?? 0; + DurationSeconds = durationSeconds ?? 0; + EventTime = eventTime; + } + + public string ModelAlias { get; private set; } + + public bool Success { get; private set; } + + public string ErrorMessage { get; private set; } + + public long FileSizeMb { get; private set; } + + public double DurationSeconds { get; private set; } + + public DateTime EventTime { get; private set; } + + public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + } + + public static void Log( + string modelAlias, + bool success, + string? errorMessage = null, + long? fileSizeMb = null, + double? durationSeconds = null) + { + if (success) + { + TelemetryFactory.Get().Log( + "FoundryLocalDownload_Event", + LogLevel.Info, + new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, fileSizeMb, durationSeconds, DateTime.Now)); + } + else + { + var relatedActivityId = Guid.NewGuid(); + TelemetryFactory.Get().LogError( + "FoundryLocalDownload_Event", + LogLevel.Critical, + new FoundryLocalDownloadEvent(modelAlias, success, errorMessage, fileSizeMb, durationSeconds, DateTime.Now), + relatedActivityId); + } + } +} + +/// +/// Telemetry event for Foundry Local errors. +/// Operation naming convention: PascalCase action names (e.g., "ClientInitialization", "ModelDownload", "ModelPrepare") +/// +[EventData] +internal class FoundryLocalErrorEvent : EventBase +{ + internal FoundryLocalErrorEvent( + string operation, + string phase, + string modelIdentifier, + string errorMessage, + DateTime eventTime) + { + Operation = operation; + Phase = phase; + ModelIdentifier = modelIdentifier; + ErrorMessage = errorMessage ?? string.Empty; + EventTime = eventTime; + } + + public string Operation { get; private set; } + + public string Phase { get; private set; } + + public string ModelIdentifier { get; private set; } + + public string ErrorMessage { get; private set; } + + public DateTime EventTime { get; private set; } + + public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; + + public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) + { + } + + public static void Log(string operation, string phase, string modelIdentifier, string errorMessage) + { + var relatedActivityId = Guid.NewGuid(); + TelemetryFactory.Get().LogError( + "FoundryLocalError_Event", + LogLevel.Critical, + new FoundryLocalErrorEvent(operation, phase, modelIdentifier, errorMessage, DateTime.Now), + relatedActivityId); + } +} \ No newline at end of file From a920b21d2cb3feedc3e8c6db996475ea79aa15c6 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 26 Dec 2025 17:43:00 +0800 Subject: [PATCH 089/115] Update FoundryLocal not available message for SDK-based implementation --- .../ModelPickerViews/FoundryLocalPickerView.xaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml index 07b6e8da..6500d81b 100644 --- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml +++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml @@ -373,7 +373,7 @@ - Install Foundry Local - + + Learn more about Foundry Local From b7618db32f9fa00d94cad5cd28a776b6b5017bac Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Fri, 26 Dec 2025 18:57:38 +0800 Subject: [PATCH 090/115] Add retry button --- .../FoundryLocalPickerView.xaml | 7 ++++++ .../FoundryLocalPickerView.xaml.cs | 23 +++++++++++++++++++ .../FoundryLocalModelProvider.cs | 20 ++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml index 6500d81b..baccbbc5 100644 --- a/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml +++ b/AIDevGallery/Controls/ModelPicker/ModelPickerViews/FoundryLocalPickerView.xaml @@ -384,6 +384,13 @@ Learn more about Foundry Local +