diff --git a/ApiTestRunner.sln b/ApiTestRunner.sln index 4de2785..a9be7c0 100644 --- a/ApiTestRunner.sln +++ b/ApiTestRunner.sln @@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiTestRunner.Core.Tests", "tests\ApiTestRunner.Core.Tests\ApiTestRunner.Core.Tests.csproj", "{EF423B95-619D-4789-81FF-36F4E58FE369}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiTestRunner.App.Tests", "tests\ApiTestRunner.App.Tests\ApiTestRunner.App.Tests.csproj", "{BB8BCC07-46B2-43AB-9812-41AD2F37E31C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +61,18 @@ Global {EF423B95-619D-4789-81FF-36F4E58FE369}.Release|x64.Build.0 = Release|Any CPU {EF423B95-619D-4789-81FF-36F4E58FE369}.Release|x86.ActiveCfg = Release|Any CPU {EF423B95-619D-4789-81FF-36F4E58FE369}.Release|x86.Build.0 = Release|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Debug|x64.ActiveCfg = Debug|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Debug|x64.Build.0 = Debug|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Debug|x86.ActiveCfg = Debug|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Debug|x86.Build.0 = Debug|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Release|Any CPU.Build.0 = Release|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Release|x64.ActiveCfg = Release|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Release|x64.Build.0 = Release|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Release|x86.ActiveCfg = Release|Any CPU + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -67,5 +81,6 @@ Global {90A14FD8-067D-46BF-BF61-045708DF1A74} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {94252802-A533-483A-A08E-1F36CEABA7DB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {EF423B95-619D-4789-81FF-36F4E58FE369} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {BB8BCC07-46B2-43AB-9812-41AD2F37E31C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 9453c1d..6460963 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ - Path params, query params, headers, and JSON request bodies - Dot-notation assertions with array index support - Validation for strings, objects, and arrays +- Toggleable test selection in the dashboard, including select-all and individual test control - Pass/fail reporting with response previews in the dashboard +- cURL analysis page that scans configured YAML definitions and generates suggested environment and endpoint YAML when missing - Configurable dashboard host, port, browser auto-launch, suite files, and concurrency through `appsettings.json` ## Requirements summary @@ -40,6 +42,38 @@ dotnet run --project src/ApiTestRunner.App -c Release The app starts the dashboard at `http://localhost:5005` by default, auto-launches the browser if enabled, and executes the split sample suite after the web server is ready. +## Dashboard workflow + +The main dashboard now exposes a test-selection panel before execution: + +- `Select All` enables the full suite. +- `Clear All` disables every test. +- Environment, endpoint, and individual test checkboxes let you run only the subset you care about. +- Each page load starts with all tests selected by default. + +Important note: + +- The runner still executes one HTTP request per endpoint. Selecting one test under an endpoint will run that endpoint once and evaluate only the selected tests against that shared response. + +## cURL import workflow + +Open `http://localhost:5005/curl-import.html` to paste a cURL command and inspect it. + +The tool will: + +- parse the request URL, method, headers, query string, and JSON body +- scan the configured YAML suite for an existing environment whose `baseUrl` already covers the pasted request URL +- scan for an existing endpoint with the same method and either the same relative path or a matching path template such as `/customers/{customerId}` +- generate suggested environment YAML when the base URL is not already present +- generate suggested endpoint YAML when the endpoint is not already present +- accept a pasted JSON response body so you can pick response fields and build assertion YAML into the generated endpoint test + +Current first-version scope: + +- best support is for common `curl`, `-X`, `-H`, `--data`, `--data-raw`, `--data-binary`, and `--url` forms +- generated YAML is shown as preview text, not written directly to disk +- the assertion builder currently targets the most common field assertions: `equals`, `notEquals`, `type`, `containsText`, `startsWith`, `endsWith`, `notEmpty`, `minCount`, `maxCount`, and `count` + ## CI/CD The repository now includes a GitHub Actions based CI/CD setup under [`.github/workflows`](D:/Projects/Research/EndpointTestRunner/.github/workflows): diff --git a/src/ApiTestRunner.App/Models/CurlContracts.cs b/src/ApiTestRunner.App/Models/CurlContracts.cs new file mode 100644 index 0000000..5478c74 --- /dev/null +++ b/src/ApiTestRunner.App/Models/CurlContracts.cs @@ -0,0 +1,75 @@ +namespace ApiTestRunner.App.Models; + +public sealed class CurlAnalyzeRequest +{ + public string Command { get; init; } = string.Empty; + + public string? ResponseBody { get; init; } + + public IReadOnlyList Assertions { get; init; } = []; +} + +public sealed class CurlAnalyzeResponse +{ + public CurlRequestSummary? Request { get; init; } + + public CurlEnvironmentAnalysis Environment { get; init; } = new(); + + public CurlEndpointAnalysis Endpoint { get; init; } = new(); +} + +public sealed class CurlRequestSummary +{ + public string Method { get; init; } = string.Empty; + + public string Url { get; init; } = string.Empty; + + public string BaseUrl { get; init; } = string.Empty; + + public string Path { get; init; } = string.Empty; + + public IReadOnlyDictionary Query { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyDictionary Headers { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public object? Body { get; init; } + + public string? RawBody { get; init; } + + public string? RelativePath { get; init; } +} + +public sealed class CurlAssertionDraft +{ + public string Field { get; init; } = string.Empty; + + public string Rule { get; init; } = string.Empty; + + public object? Value { get; init; } +} + +public sealed class CurlEnvironmentAnalysis +{ + public bool Exists { get; init; } + + public string SuggestedName { get; init; } = string.Empty; + + public IReadOnlyList MatchedEnvironmentNames { get; init; } = []; + + public string? SuggestedFilePath { get; init; } + + public string? SuggestedYaml { get; init; } +} + +public sealed class CurlEndpointAnalysis +{ + public bool Exists { get; init; } + + public string SuggestedName { get; init; } = string.Empty; + + public IReadOnlyList MatchedEnvironmentNames { get; init; } = []; + + public string? SuggestedFilePath { get; init; } + + public string? SuggestedYaml { get; init; } +} diff --git a/src/ApiTestRunner.App/Models/DashboardContracts.cs b/src/ApiTestRunner.App/Models/DashboardContracts.cs new file mode 100644 index 0000000..f16f46d --- /dev/null +++ b/src/ApiTestRunner.App/Models/DashboardContracts.cs @@ -0,0 +1,58 @@ +using ApiTestRunner.Core.Models; + +namespace ApiTestRunner.App.Models; + +public sealed class TestSelectionRequest +{ + public bool RunAll { get; init; } = true; + + public IReadOnlyList SelectedTestIds { get; init; } = []; +} + +public sealed class DashboardSuiteManifest +{ + public IReadOnlyList Environments { get; init; } = []; + + public int TotalEndpoints => Environments.Sum(environment => environment.Endpoints.Count); + + public int TotalTests => Environments.Sum(environment => environment.TotalTests); +} + +public sealed class DashboardEnvironmentManifest +{ + public string Id { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public string BaseUrl { get; init; } = string.Empty; + + public IReadOnlyList Endpoints { get; init; } = []; + + public int TotalTests => Endpoints.Sum(endpoint => endpoint.Tests.Count); +} + +public sealed class DashboardEndpointManifest +{ + public string Id { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public string Method { get; init; } = string.Empty; + + public string Path { get; init; } = string.Empty; + + public IReadOnlyList Tests { get; init; } = []; +} + +public sealed class DashboardTestManifest +{ + public string Id { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public int ExpectedStatus { get; init; } +} + +public sealed record LoadedTestSuite( + ApiTestSuiteDefinition Suite, + IReadOnlyList FilePaths); diff --git a/src/ApiTestRunner.App/Program.cs b/src/ApiTestRunner.App/Program.cs index 1c66a2f..30fab4d 100644 --- a/src/ApiTestRunner.App/Program.cs +++ b/src/ApiTestRunner.App/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using ApiTestRunner.App.Models; using ApiTestRunner.App.Options; using ApiTestRunner.App.Services; using ApiTestRunner.Core.Extensions; @@ -17,6 +18,8 @@ builder.Services.Configure(builder.Configuration.GetSection(ExecutionOptions.SectionName)); builder.Services.AddApiTestRunnerCore(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -36,9 +39,25 @@ return Results.Ok(coordinator.GetState()); }); -app.MapPost("/api/dashboard/run", async (TestRunCoordinator coordinator, CancellationToken cancellationToken) => +app.MapGet("/api/dashboard/manifest", async (TestRunCoordinator coordinator, CancellationToken cancellationToken) => { - var result = await coordinator.ExecuteAsync(cancellationToken); + var manifest = await coordinator.GetManifestAsync(cancellationToken); + return Results.Ok(manifest); +}); + +app.MapPost("/api/dashboard/run", async (HttpRequest request, TestRunCoordinator coordinator, CancellationToken cancellationToken) => +{ + var selection = request.ContentLength > 0 + ? await request.ReadFromJsonAsync(cancellationToken) + : null; + + var result = await coordinator.ExecuteAsync(selection, cancellationToken); + return Results.Ok(result); +}); + +app.MapPost("/api/tools/curl/analyze", async (CurlAnalyzeRequest request, ICurlCommandAnalyzer analyzer, CancellationToken cancellationToken) => +{ + var result = await analyzer.AnalyzeAsync(request, cancellationToken); return Results.Ok(result); }); diff --git a/src/ApiTestRunner.App/Services/ConfiguredTestSuiteProvider.cs b/src/ApiTestRunner.App/Services/ConfiguredTestSuiteProvider.cs new file mode 100644 index 0000000..1e9d8ea --- /dev/null +++ b/src/ApiTestRunner.App/Services/ConfiguredTestSuiteProvider.cs @@ -0,0 +1,177 @@ +using System.Text.RegularExpressions; +using ApiTestRunner.App.Models; +using ApiTestRunner.App.Options; +using ApiTestRunner.Core.Services; +using Microsoft.Extensions.Options; + +namespace ApiTestRunner.App.Services; + +public sealed class ConfiguredTestSuiteProvider : IConfiguredTestSuiteProvider +{ + private static readonly char[] WildcardCharacters = ['*', '?']; + + private readonly IYamlTestSuiteLoader _loader; + private readonly IOptions _executionOptions; + private readonly IHostEnvironment _hostEnvironment; + + public ConfiguredTestSuiteProvider( + IYamlTestSuiteLoader loader, + IOptions executionOptions, + IHostEnvironment hostEnvironment) + { + _loader = loader; + _executionOptions = executionOptions; + _hostEnvironment = hostEnvironment; + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + var filePaths = ResolveConfiguredFiles(_executionOptions.Value.TestFiles); + var suite = await _loader.LoadAsync(filePaths, cancellationToken); + return new LoadedTestSuite(suite, filePaths); + } + + private IReadOnlyList ResolveConfiguredFiles(IEnumerable configuredEntries) + { + var expandedFiles = new List(); + + foreach (var entry in configuredEntries.Where(value => !string.IsNullOrWhiteSpace(value))) + { + if (ContainsWildcard(entry)) + { + expandedFiles.AddRange(ResolveGlob(entry)); + continue; + } + + var resolvedPath = ResolvePath(entry); + + if (Directory.Exists(resolvedPath)) + { + expandedFiles.AddRange(Directory + .EnumerateFiles(resolvedPath, "*.yaml", SearchOption.AllDirectories) + .Concat(Directory.EnumerateFiles(resolvedPath, "*.yml", SearchOption.AllDirectories))); + continue; + } + + expandedFiles.Add(resolvedPath); + } + + var files = expandedFiles + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (files.Length == 0) + { + throw new InvalidOperationException("Execution:TestFiles must contain at least one YAML file or matching glob pattern."); + } + + return files; + } + + private IReadOnlyList ResolveGlob(string pattern) + { + var fullPattern = ResolvePath(pattern); + var searchRoot = GetSearchRoot(fullPattern); + + if (!Directory.Exists(searchRoot)) + { + throw new DirectoryNotFoundException( + $"The directory portion of glob pattern '{pattern}' does not exist: '{searchRoot}'."); + } + + var relativePattern = Path.GetRelativePath(searchRoot, fullPattern) + .Replace('\\', '/'); + + var regex = BuildGlobRegex(relativePattern); + var matches = Directory + .EnumerateFiles(searchRoot, "*", SearchOption.AllDirectories) + .Where(path => + { + var relativePath = Path.GetRelativePath(searchRoot, path).Replace('\\', '/'); + return regex.IsMatch(relativePath); + }) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (matches.Length == 0) + { + throw new InvalidOperationException($"Glob pattern '{pattern}' did not match any files."); + } + + return matches; + } + + private string ResolvePath(string configuredPath) + { + return Path.IsPathRooted(configuredPath) + ? configuredPath + : Path.GetFullPath(Path.Combine(_hostEnvironment.ContentRootPath, configuredPath)); + } + + private static bool ContainsWildcard(string value) + { + return value.IndexOfAny(WildcardCharacters) >= 0; + } + + private static string GetSearchRoot(string fullPattern) + { + var normalizedPattern = fullPattern.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + var root = Path.GetPathRoot(normalizedPattern) ?? string.Empty; + var remainder = normalizedPattern[root.Length..]; + var segments = remainder.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = string.IsNullOrEmpty(root) ? string.Empty : root; + + foreach (var segment in segments) + { + if (segment.Contains('*') || segment.Contains('?')) + { + break; + } + + current = string.IsNullOrEmpty(current) + ? segment + : Path.Combine(current, segment); + } + + return string.IsNullOrEmpty(current) ? Directory.GetCurrentDirectory() : current; + } + + private static Regex BuildGlobRegex(string relativePattern) + { + var pattern = relativePattern.Replace('\\', '/'); + var regexPattern = "^"; + + for (var index = 0; index < pattern.Length; index++) + { + var character = pattern[index]; + + if (character == '*') + { + var hasDoubleStar = index + 1 < pattern.Length && pattern[index + 1] == '*'; + if (hasDoubleStar) + { + var followedByDirectorySeparator = index + 2 < pattern.Length && pattern[index + 2] == '/'; + regexPattern += followedByDirectorySeparator ? @"(?:.*/)?" : @".*"; + index += followedByDirectorySeparator ? 2 : 1; + continue; + } + + regexPattern += @"[^/]*"; + continue; + } + + if (character == '?') + { + regexPattern += @"[^/]"; + continue; + } + + regexPattern += Regex.Escape(character.ToString()); + } + + regexPattern += "$"; + + return new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } +} diff --git a/src/ApiTestRunner.App/Services/CurlCommandAnalyzer.cs b/src/ApiTestRunner.App/Services/CurlCommandAnalyzer.cs new file mode 100644 index 0000000..f078042 --- /dev/null +++ b/src/ApiTestRunner.App/Services/CurlCommandAnalyzer.cs @@ -0,0 +1,742 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using ApiTestRunner.App.Models; +using ApiTestRunner.Core.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace ApiTestRunner.App.Services; + +public sealed class CurlCommandAnalyzer : ICurlCommandAnalyzer +{ + private static readonly Regex LineContinuationRegex = new(@"([\\`^])\s*\r?\n\s*", RegexOptions.Compiled); + private static readonly Regex TemplateSegmentRegex = new(@"\{[^{}]+\}", RegexOptions.Compiled); + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly IConfiguredTestSuiteProvider _suiteProvider; + private readonly ISerializer _yamlSerializer; + + public CurlCommandAnalyzer(IConfiguredTestSuiteProvider suiteProvider) + { + _suiteProvider = suiteProvider; + _yamlSerializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build(); + } + + public async Task AnalyzeAsync(CurlAnalyzeRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.Command)) + { + throw new InvalidOperationException("Provide a cURL command to analyze."); + } + + var parsedRequest = ParseCurlCommand(request.Command); + var loadedSuite = await _suiteProvider.LoadAsync(cancellationToken); + + var matchedEnvironmentInfos = loadedSuite.Suite.Environments + .Select(environment => TryMatchEnvironment(environment, parsedRequest.Url)) + .Where(match => match is not null) + .Select(match => match!) + .OrderByDescending(match => NormalizeBaseUrl(match.Environment.BaseUrl).Length) + .ThenBy(match => match.Environment.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var matchedEnvironments = matchedEnvironmentInfos + .Select(match => match.Environment) + .DistinctBy(environment => environment.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var effectivePath = matchedEnvironmentInfos.FirstOrDefault()?.RelativePath ?? parsedRequest.Path; + var requestSummary = new CurlRequestSummary + { + Method = parsedRequest.Method, + Url = parsedRequest.Url, + BaseUrl = parsedRequest.BaseUrl, + Path = parsedRequest.Path, + RelativePath = effectivePath, + Query = parsedRequest.Query, + Headers = parsedRequest.Headers, + Body = parsedRequest.Body, + RawBody = parsedRequest.RawBody + }; + + var matchedEndpointInfos = matchedEnvironmentInfos + .Where(match => match.Environment.Endpoints.Any(endpoint => + MethodsMatch(endpoint.Method, parsedRequest.Method) && + PathsMatch(endpoint.Path, match.RelativePath))) + .ToArray(); + + var matchedEndpointEnvironments = matchedEndpointInfos + .Select(match => match.Environment.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var suggestedEnvironmentName = matchedEnvironments.Length > 0 + ? matchedEnvironments[0].Name + : SuggestEnvironmentName(parsedRequest.BaseUrl); + + var targetEnvironmentNames = matchedEnvironmentInfos.Length > 0 + ? matchedEnvironmentInfos + .Where(match => string.Equals(match.RelativePath, effectivePath, StringComparison.OrdinalIgnoreCase)) + .Select(match => match.Environment.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + : [suggestedEnvironmentName]; + + return new CurlAnalyzeResponse + { + Request = requestSummary, + Environment = new CurlEnvironmentAnalysis + { + Exists = matchedEnvironments.Length > 0, + SuggestedName = suggestedEnvironmentName, + MatchedEnvironmentNames = matchedEnvironments.Select(environment => environment.Name).ToArray(), + SuggestedFilePath = matchedEnvironments.Length > 0 + ? null + : BuildEnvironmentFilePath(suggestedEnvironmentName), + SuggestedYaml = matchedEnvironments.Length > 0 + ? null + : GenerateEnvironmentYaml(suggestedEnvironmentName, parsedRequest.BaseUrl) + }, + Endpoint = new CurlEndpointAnalysis + { + Exists = matchedEndpointEnvironments.Length > 0, + SuggestedName = SuggestEndpointName(parsedRequest.Method, effectivePath), + MatchedEnvironmentNames = matchedEndpointEnvironments, + SuggestedFilePath = matchedEndpointEnvironments.Length > 0 + ? null + : BuildEndpointFilePath(parsedRequest.Method, effectivePath), + SuggestedYaml = matchedEndpointEnvironments.Length > 0 + ? null + : GenerateEndpointYaml( + parsedRequest, + effectivePath, + targetEnvironmentNames, + request.Assertions) + } + }; + } + + private static CurlRequestSummary ParseCurlCommand(string command) + { + var normalizedCommand = LineContinuationRegex.Replace(command.Trim(), " "); + var tokens = Tokenize(normalizedCommand); + + if (tokens.Count == 0) + { + throw new InvalidOperationException("The provided cURL command was empty."); + } + + var currentIndex = 0; + if (tokens[0].Equals("curl", StringComparison.OrdinalIgnoreCase) || + tokens[0].Equals("curl.exe", StringComparison.OrdinalIgnoreCase)) + { + currentIndex++; + } + + string? method = null; + string? url = null; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + var bodySegments = new List(); + + while (currentIndex < tokens.Count) + { + var token = tokens[currentIndex]; + + switch (token) + { + case "-X": + case "--request": + method = ReadNextValue(tokens, ref currentIndex, token); + break; + case "-H": + case "--header": + var headerValue = ReadNextValue(tokens, ref currentIndex, token); + AddHeader(headers, headerValue); + break; + case "-d": + case "--data": + case "--data-raw": + case "--data-binary": + bodySegments.Add(ReadNextValue(tokens, ref currentIndex, token)); + method ??= HttpMethod.Post.Method; + break; + case "--url": + url = ReadNextValue(tokens, ref currentIndex, token); + break; + default: + if (token.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + url = token; + } + + break; + } + + currentIndex++; + } + + if (string.IsNullOrWhiteSpace(url)) + { + throw new InvalidOperationException("No URL was found in the cURL command."); + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException($"The cURL command contained an invalid URL: {url}"); + } + + var combinedBody = bodySegments.Count == 0 ? null : string.Join(Environment.NewLine, bodySegments); + + return new CurlRequestSummary + { + Method = (method ?? (combinedBody is null ? HttpMethod.Get.Method : HttpMethod.Post.Method)).ToUpperInvariant(), + Url = uri.ToString(), + BaseUrl = $"{uri.Scheme}://{uri.Authority}", + Path = string.IsNullOrWhiteSpace(uri.AbsolutePath) ? "/" : uri.AbsolutePath, + Query = ParseQuery(uri), + Headers = headers, + Body = TryParseJsonBody(combinedBody), + RawBody = combinedBody + }; + } + + private static IReadOnlyDictionary ParseQuery(Uri uri) + { + if (string.IsNullOrWhiteSpace(uri.Query)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var query = new Dictionary(StringComparer.OrdinalIgnoreCase); + var segments = uri.Query.TrimStart('?') + .Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var segment in segments) + { + var parts = segment.Split('=', 2); + var key = Uri.UnescapeDataString(parts[0]); + var value = parts.Length == 2 ? Uri.UnescapeDataString(parts[1]) : string.Empty; + query[key] = value; + } + + return query; + } + + private static object? TryParseJsonBody(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + try + { + var node = JsonNode.Parse(body); + return ConvertJsonNode(node); + } + catch (JsonException) + { + return body; + } + } + + private static object? ConvertJsonNode(JsonNode? node) + { + return node switch + { + null => null, + JsonObject jsonObject => jsonObject.ToDictionary( + pair => pair.Key, + pair => ConvertJsonNode(pair.Value), + StringComparer.OrdinalIgnoreCase), + JsonArray jsonArray => jsonArray.Select(ConvertJsonNode).ToList(), + JsonValue jsonValue => ConvertJsonScalar(jsonValue), + _ => node.ToJsonString(JsonSerializerOptions) + }; + } + + private static object? ConvertJsonScalar(JsonValue value) + { + if (value.TryGetValue(out var stringValue)) + { + return stringValue; + } + + if (value.TryGetValue(out var booleanValue)) + { + return booleanValue; + } + + if (value.TryGetValue(out var intValue)) + { + return intValue; + } + + if (value.TryGetValue(out var longValue)) + { + return longValue; + } + + if (value.TryGetValue(out var decimalValue)) + { + return decimalValue; + } + + if (value.TryGetValue(out var doubleValue)) + { + return doubleValue; + } + + return value.ToJsonString(); + } + + private static List Tokenize(string command) + { + var tokens = new List(); + var current = new StringBuilder(); + var inSingleQuotes = false; + var inDoubleQuotes = false; + + for (var index = 0; index < command.Length; index++) + { + var character = command[index]; + + if (inSingleQuotes) + { + if (character == '\'') + { + inSingleQuotes = false; + } + else + { + current.Append(character); + } + + continue; + } + + if (inDoubleQuotes) + { + if (character == '"' && !IsEscaped(command, index)) + { + inDoubleQuotes = false; + } + else if (character == '\\' && index + 1 < command.Length) + { + current.Append(command[++index]); + } + else + { + current.Append(character); + } + + continue; + } + + if (char.IsWhiteSpace(character)) + { + FlushToken(tokens, current); + continue; + } + + if (character == '\'') + { + inSingleQuotes = true; + continue; + } + + if (character == '"') + { + inDoubleQuotes = true; + continue; + } + + if (character == '\\' && index + 1 < command.Length) + { + current.Append(command[++index]); + continue; + } + + current.Append(character); + } + + if (inSingleQuotes || inDoubleQuotes) + { + throw new InvalidOperationException("The cURL command contains an unmatched quote."); + } + + FlushToken(tokens, current); + return tokens; + } + + private static void FlushToken(ICollection tokens, StringBuilder current) + { + if (current.Length == 0) + { + return; + } + + tokens.Add(current.ToString()); + current.Clear(); + } + + private static bool IsEscaped(string input, int index) + { + var slashCount = 0; + + for (var i = index - 1; i >= 0 && input[i] == '\\'; i--) + { + slashCount++; + } + + return slashCount % 2 == 1; + } + + private static string ReadNextValue(IReadOnlyList tokens, ref int currentIndex, string option) + { + if (currentIndex + 1 >= tokens.Count) + { + throw new InvalidOperationException($"The cURL option '{option}' is missing its value."); + } + + currentIndex++; + return tokens[currentIndex]; + } + + private static void AddHeader(IDictionary headers, string headerValue) + { + var separatorIndex = headerValue.IndexOf(':'); + if (separatorIndex <= 0) + { + return; + } + + var headerName = headerValue[..separatorIndex].Trim(); + var headerContent = headerValue[(separatorIndex + 1)..].Trim(); + + if (string.IsNullOrWhiteSpace(headerName)) + { + return; + } + + headers[headerName] = headerContent; + } + + private static EnvironmentMatch? TryMatchEnvironment(EnvironmentDefinition environment, string requestUrl) + { + if (!Uri.TryCreate(environment.BaseUrl, UriKind.Absolute, out var environmentUri) || + !Uri.TryCreate(requestUrl, UriKind.Absolute, out var requestUri)) + { + return null; + } + + if (!string.Equals(environmentUri.Scheme, requestUri.Scheme, StringComparison.OrdinalIgnoreCase) || + !string.Equals(environmentUri.Host, requestUri.Host, StringComparison.OrdinalIgnoreCase) || + environmentUri.Port != requestUri.Port) + { + return null; + } + + var environmentPath = NormalizePath(environmentUri.AbsolutePath); + var requestPath = NormalizePath(requestUri.AbsolutePath); + + if (!PathStartsWith(requestPath, environmentPath)) + { + return null; + } + + var relativePath = requestPath[environmentPath.Length..]; + if (string.IsNullOrWhiteSpace(relativePath)) + { + relativePath = "/"; + } + else if (!relativePath.StartsWith('/')) + { + relativePath = "/" + relativePath; + } + + return new EnvironmentMatch(environment, NormalizePath(relativePath)); + } + + private static bool PathStartsWith(string requestPath, string environmentPath) + { + if (string.Equals(environmentPath, "/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!requestPath.StartsWith(environmentPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return requestPath.Length == environmentPath.Length || + requestPath[environmentPath.Length] == '/'; + } + + private static string NormalizeBaseUrl(string value) + { + return value.Trim().TrimEnd('/'); + } + + private static bool MethodsMatch(string left, string right) + { + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static bool PathsMatch(string templatePath, string actualPath) + { + var normalizedTemplatePath = NormalizePath(templatePath); + var normalizedActualPath = NormalizePath(actualPath); + + if (string.Equals(normalizedTemplatePath, normalizedActualPath, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var templateSegments = normalizedTemplatePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + var actualSegments = normalizedActualPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (templateSegments.Length != actualSegments.Length) + { + return false; + } + + for (var index = 0; index < templateSegments.Length; index++) + { + if (TemplateSegmentRegex.IsMatch(templateSegments[index])) + { + continue; + } + + if (!string.Equals(templateSegments[index], actualSegments[index], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + var normalized = path.Trim(); + if (!normalized.StartsWith('/')) + { + normalized = "/" + normalized; + } + + return normalized.Length > 1 ? normalized.TrimEnd('/') : normalized; + } + + private string GenerateEnvironmentYaml(string environmentName, string baseUrl) + { + var document = new + { + environments = new[] + { + new + { + name = environmentName, + baseUrl + } + } + }; + + return _yamlSerializer.Serialize(document).Trim(); + } + + private string GenerateEndpointYaml( + CurlRequestSummary request, + string endpointPath, + IReadOnlyList targetEnvironmentNames, + IReadOnlyList assertions) + { + var endpointDocument = new Dictionary + { + ["targetEnvironments"] = targetEnvironmentNames, + ["endpoints"] = new[] + { + BuildEndpointDocument(request, endpointPath, assertions) + } + }; + + return _yamlSerializer.Serialize(endpointDocument).Trim(); + } + + private Dictionary BuildEndpointDocument( + CurlRequestSummary request, + string endpointPath, + IReadOnlyList assertions) + { + var endpoint = new Dictionary + { + ["name"] = SuggestEndpointName(request.Method, endpointPath), + ["method"] = request.Method, + ["path"] = endpointPath + }; + + if (request.Headers.Count > 0) + { + endpoint["headers"] = request.Headers + .Where(pair => !string.Equals(pair.Key, "Host", StringComparison.OrdinalIgnoreCase) && + !string.Equals(pair.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(pair => pair.Key, pair => (object?)pair.Value, StringComparer.OrdinalIgnoreCase); + } + + if (request.Query.Count > 0) + { + endpoint["query"] = request.Query.ToDictionary(pair => pair.Key, pair => (object?)pair.Value, StringComparer.OrdinalIgnoreCase); + } + + if (request.Body is not null) + { + endpoint["body"] = request.Body; + } + + var testDefinition = new Dictionary + { + ["name"] = $"{SuggestEndpointName(request.Method, endpointPath)} should return success", + ["expectedStatus"] = 200 + }; + + var assertionDocuments = BuildAssertionDocuments(assertions); + if (assertionDocuments.Count > 0) + { + testDefinition["assertions"] = assertionDocuments; + } + + endpoint["tests"] = new[] { testDefinition }; + return endpoint; + } + + private static List> BuildAssertionDocuments(IReadOnlyList assertions) + { + var documents = new List>(); + + foreach (var assertion in assertions) + { + if (string.IsNullOrWhiteSpace(assertion.Field) || string.IsNullOrWhiteSpace(assertion.Rule)) + { + continue; + } + + var document = new Dictionary + { + ["field"] = assertion.Field + }; + + var rule = assertion.Rule.Trim(); + var normalizedRule = char.ToLowerInvariant(rule[0]) + rule[1..]; + document[normalizedRule] = ConvertAssertionValue(assertion.Value); + documents.Add(document); + } + + return documents; + } + + private static object? ConvertAssertionValue(object? value) + { + return value switch + { + null => null, + JsonElement element => ConvertJsonElement(element), + _ => value + }; + } + + private static object? ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number when element.TryGetInt32(out var intValue) => intValue, + JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => decimalValue, + JsonValueKind.Object => JsonSerializer.Deserialize>(element.GetRawText(), JsonSerializerOptions), + JsonValueKind.Array => JsonSerializer.Deserialize>(element.GetRawText(), JsonSerializerOptions), + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + } + + private static string SuggestEnvironmentName(string baseUrl) + { + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)) + { + return "GeneratedEnvironment"; + } + + var hostSegments = uri.Host + .Split('.', StringSplitOptions.RemoveEmptyEntries) + .Select(ToTitleCaseToken) + .ToArray(); + + var portSuffix = uri.IsDefaultPort ? string.Empty : uri.Port.ToString(); + var suggested = string.Concat(hostSegments) + portSuffix; + return string.IsNullOrWhiteSpace(suggested) ? "GeneratedEnvironment" : suggested; + } + + private static string SuggestEndpointName(string method, string path) + { + var segments = path + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(segment => TemplateSegmentRegex.IsMatch(segment) ? "ByParameter" : ToTitleCaseToken(segment)) + .ToArray(); + + var pathPart = segments.Length == 0 ? "Root" : string.Concat(segments); + return $"{method.ToUpperInvariant()} {pathPart}"; + } + + private static string BuildEnvironmentFilePath(string environmentName) + { + return $"samples/environments/{ToSlug(environmentName)}.yaml"; + } + + private static string BuildEndpointFilePath(string method, string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var folder = segments.Length > 0 ? ToSlug(segments[0]) : "root"; + var fileName = $"{method.ToLowerInvariant()}-{ToSlug(segments.LastOrDefault() ?? "root")}.yaml"; + return $"samples/endpoints/{folder}/{fileName}"; + } + + private static string ToTitleCaseToken(string value) + { + var sanitized = Regex.Replace(value, @"[^A-Za-z0-9]+", " "); + var words = sanitized + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()); + + return string.Concat(words); + } + + private static string ToSlug(string value) + { + var normalized = Regex.Replace(value, @"[^A-Za-z0-9]+", "-") + .Trim('-') + .ToLowerInvariant(); + + return string.IsNullOrWhiteSpace(normalized) ? "generated" : normalized; + } + + private sealed record EnvironmentMatch(EnvironmentDefinition Environment, string RelativePath); +} diff --git a/src/ApiTestRunner.App/Services/DashboardSuiteManifestFactory.cs b/src/ApiTestRunner.App/Services/DashboardSuiteManifestFactory.cs new file mode 100644 index 0000000..7c2b5a1 --- /dev/null +++ b/src/ApiTestRunner.App/Services/DashboardSuiteManifestFactory.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; +using System.Text; +using ApiTestRunner.App.Models; +using ApiTestRunner.Core.Models; + +namespace ApiTestRunner.App.Services; + +public static class DashboardSuiteManifestFactory +{ + public static DashboardSuiteManifest Create(ApiTestSuiteDefinition suite) + { + ArgumentNullException.ThrowIfNull(suite); + + return new DashboardSuiteManifest + { + Environments = suite.Environments + .OrderBy(environment => environment.Name, StringComparer.OrdinalIgnoreCase) + .Select(BuildEnvironmentManifest) + .ToArray() + }; + } + + public static ApiTestSuiteDefinition Filter(ApiTestSuiteDefinition suite, IReadOnlyCollection selectedTestIds) + { + ArgumentNullException.ThrowIfNull(suite); + ArgumentNullException.ThrowIfNull(selectedTestIds); + + var selectedSet = new HashSet(selectedTestIds, StringComparer.OrdinalIgnoreCase); + var filteredEnvironments = new List(); + + foreach (var environment in suite.Environments) + { + var filteredEndpoints = new List(); + + for (var endpointIndex = 0; endpointIndex < environment.Endpoints.Count; endpointIndex++) + { + var endpoint = environment.Endpoints[endpointIndex]; + var filteredTests = new List(); + + for (var testIndex = 0; testIndex < endpoint.Tests.Count; testIndex++) + { + var test = endpoint.Tests[testIndex]; + var testId = CreateTestId(environment, endpoint, test, testIndex); + + if (selectedSet.Contains(testId)) + { + filteredTests.Add(test); + } + } + + if (filteredTests.Count == 0) + { + continue; + } + + filteredEndpoints.Add(endpoint with + { + Tests = filteredTests + }); + } + + if (filteredEndpoints.Count == 0) + { + continue; + } + + filteredEnvironments.Add(environment with + { + Endpoints = filteredEndpoints + }); + } + + return new ApiTestSuiteDefinition + { + Environments = filteredEnvironments + }; + } + + public static string CreateTestId( + EnvironmentDefinition environment, + EndpointDefinition endpoint, + TestDefinition test, + int testIndex) + { + return CreateStableId( + "test", + environment.Name, + endpoint.Method, + endpoint.Path, + endpoint.Name, + test.Name, + testIndex.ToString()); + } + + private static DashboardEnvironmentManifest BuildEnvironmentManifest(EnvironmentDefinition environment) + { + return new DashboardEnvironmentManifest + { + Id = CreateStableId("environment", environment.Name, environment.BaseUrl), + Name = environment.Name, + BaseUrl = environment.BaseUrl, + Endpoints = environment.Endpoints + .OrderBy(endpoint => endpoint.Name, StringComparer.OrdinalIgnoreCase) + .Select(endpoint => BuildEndpointManifest(environment, endpoint)) + .ToArray() + }; + } + + private static DashboardEndpointManifest BuildEndpointManifest(EnvironmentDefinition environment, EndpointDefinition endpoint) + { + return new DashboardEndpointManifest + { + Id = CreateStableId("endpoint", environment.Name, endpoint.Method, endpoint.Path, endpoint.Name), + Name = endpoint.Name, + Method = endpoint.Method.ToUpperInvariant(), + Path = endpoint.Path, + Tests = endpoint.Tests + .Select((test, index) => new DashboardTestManifest + { + Id = CreateTestId(environment, endpoint, test, index), + Name = test.Name, + ExpectedStatus = test.ExpectedStatus + }) + .ToArray() + }; + } + + private static string CreateStableId(params string[] parts) + { + var raw = string.Join("|", parts); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))); + } +} diff --git a/src/ApiTestRunner.App/Services/IConfiguredTestSuiteProvider.cs b/src/ApiTestRunner.App/Services/IConfiguredTestSuiteProvider.cs new file mode 100644 index 0000000..fff80c3 --- /dev/null +++ b/src/ApiTestRunner.App/Services/IConfiguredTestSuiteProvider.cs @@ -0,0 +1,8 @@ +using ApiTestRunner.App.Models; + +namespace ApiTestRunner.App.Services; + +public interface IConfiguredTestSuiteProvider +{ + Task LoadAsync(CancellationToken cancellationToken = default); +} diff --git a/src/ApiTestRunner.App/Services/ICurlCommandAnalyzer.cs b/src/ApiTestRunner.App/Services/ICurlCommandAnalyzer.cs new file mode 100644 index 0000000..b5f6bfe --- /dev/null +++ b/src/ApiTestRunner.App/Services/ICurlCommandAnalyzer.cs @@ -0,0 +1,8 @@ +using ApiTestRunner.App.Models; + +namespace ApiTestRunner.App.Services; + +public interface ICurlCommandAnalyzer +{ + Task AnalyzeAsync(CurlAnalyzeRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/ApiTestRunner.App/Services/StartupAutomationHostedService.cs b/src/ApiTestRunner.App/Services/StartupAutomationHostedService.cs index b188ca6..5d10762 100644 --- a/src/ApiTestRunner.App/Services/StartupAutomationHostedService.cs +++ b/src/ApiTestRunner.App/Services/StartupAutomationHostedService.cs @@ -36,7 +36,7 @@ public Task StartAsync(CancellationToken cancellationToken) TryLaunchBrowser(dashboardUrl); } - await _testRunCoordinator.ExecuteAsync(CancellationToken.None); + await _testRunCoordinator.ExecuteAsync(selectionRequest: null, cancellationToken: CancellationToken.None); }); }); diff --git a/src/ApiTestRunner.App/Services/TestRunCoordinator.cs b/src/ApiTestRunner.App/Services/TestRunCoordinator.cs index 2b268f1..3070fb2 100644 --- a/src/ApiTestRunner.App/Services/TestRunCoordinator.cs +++ b/src/ApiTestRunner.App/Services/TestRunCoordinator.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using ApiTestRunner.App.Models; using ApiTestRunner.App.Options; using ApiTestRunner.Core.Models; using ApiTestRunner.Core.Services; @@ -8,28 +8,23 @@ namespace ApiTestRunner.App.Services; public sealed class TestRunCoordinator { - private static readonly char[] WildcardCharacters = ['*', '?']; - - private readonly IYamlTestSuiteLoader _loader; + private readonly IConfiguredTestSuiteProvider _suiteProvider; private readonly IApiTestExecutor _executor; private readonly IOptions _executionOptions; - private readonly IHostEnvironment _hostEnvironment; private readonly ILogger _logger; private readonly SemaphoreSlim _runLock = new(1, 1); private DashboardState _currentState = DashboardState.NotStarted(); public TestRunCoordinator( - IYamlTestSuiteLoader loader, + IConfiguredTestSuiteProvider suiteProvider, IApiTestExecutor executor, IOptions executionOptions, - IHostEnvironment hostEnvironment, ILogger logger) { - _loader = loader; + _suiteProvider = suiteProvider; _executor = executor; _executionOptions = executionOptions; - _hostEnvironment = hostEnvironment; _logger = logger; } @@ -38,7 +33,15 @@ public DashboardState GetState() return _currentState; } - public async Task ExecuteAsync(CancellationToken cancellationToken = default) + public async Task GetManifestAsync(CancellationToken cancellationToken = default) + { + var loadedSuite = await _suiteProvider.LoadAsync(cancellationToken); + return DashboardSuiteManifestFactory.Create(loadedSuite.Suite); + } + + public async Task ExecuteAsync( + TestSelectionRequest? selectionRequest, + CancellationToken cancellationToken = default) { await _runLock.WaitAsync(cancellationToken); @@ -52,8 +55,8 @@ public async Task ExecuteAsync(CancellationToken cancellationTok LastError = null }; - var filePaths = ResolveConfiguredFiles(_executionOptions.Value.TestFiles); - var suite = await _loader.LoadAsync(filePaths, cancellationToken); + var loadedSuite = await _suiteProvider.LoadAsync(cancellationToken); + var suite = FilterSuiteForExecution(loadedSuite.Suite, selectionRequest); var result = await _executor.RunAsync(suite, _executionOptions.Value.MaxConcurrency, cancellationToken); _currentState = _currentState with @@ -86,148 +89,28 @@ public async Task ExecuteAsync(CancellationToken cancellationTok } } - private IReadOnlyList ResolveConfiguredFiles(IEnumerable configuredEntries) + private static ApiTestRunner.Core.Models.ApiTestSuiteDefinition FilterSuiteForExecution( + ApiTestRunner.Core.Models.ApiTestSuiteDefinition suite, + TestSelectionRequest? selectionRequest) { - var expandedFiles = new List(); - - foreach (var entry in configuredEntries.Where(value => !string.IsNullOrWhiteSpace(value))) - { - if (ContainsWildcard(entry)) - { - expandedFiles.AddRange(ResolveGlob(entry)); - continue; - } - - var resolvedPath = ResolvePath(entry); - - if (Directory.Exists(resolvedPath)) - { - expandedFiles.AddRange(Directory - .EnumerateFiles(resolvedPath, "*.yaml", SearchOption.AllDirectories) - .Concat(Directory.EnumerateFiles(resolvedPath, "*.yml", SearchOption.AllDirectories))); - continue; - } - - expandedFiles.Add(resolvedPath); - } - - var files = expandedFiles - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (files.Length == 0) + if (selectionRequest is null || selectionRequest.RunAll) { - throw new InvalidOperationException("Execution:TestFiles must contain at least one YAML file or matching glob pattern."); + return suite; } - return files; - } - - private IReadOnlyList ResolveGlob(string pattern) - { - var fullPattern = ResolvePath(pattern); - var searchRoot = GetSearchRoot(fullPattern); - - if (!Directory.Exists(searchRoot)) + if (selectionRequest.SelectedTestIds.Count == 0) { - throw new DirectoryNotFoundException( - $"The directory portion of glob pattern '{pattern}' does not exist: '{searchRoot}'."); + throw new InvalidOperationException("Select at least one test before running a filtered suite."); } - var relativePattern = Path.GetRelativePath(searchRoot, fullPattern) - .Replace('\\', '/'); + var filteredSuite = DashboardSuiteManifestFactory.Filter(suite, selectionRequest.SelectedTestIds); - var regex = BuildGlobRegex(relativePattern); - var matches = Directory - .EnumerateFiles(searchRoot, "*", SearchOption.AllDirectories) - .Where(path => - { - var relativePath = Path.GetRelativePath(searchRoot, path).Replace('\\', '/'); - return regex.IsMatch(relativePath); - }) - .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (matches.Length == 0) + if (filteredSuite.Environments.Count == 0) { - throw new InvalidOperationException($"Glob pattern '{pattern}' did not match any files."); + throw new InvalidOperationException("The selected tests were not found in the loaded YAML suite."); } - return matches; - } - - private string ResolvePath(string configuredPath) - { - return Path.IsPathRooted(configuredPath) - ? configuredPath - : Path.GetFullPath(Path.Combine(_hostEnvironment.ContentRootPath, configuredPath)); - } - - private static bool ContainsWildcard(string value) - { - return value.IndexOfAny(WildcardCharacters) >= 0; - } - - private static string GetSearchRoot(string fullPattern) - { - var normalizedPattern = fullPattern.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - var root = Path.GetPathRoot(normalizedPattern) ?? string.Empty; - var remainder = normalizedPattern[root.Length..]; - var segments = remainder.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); - var current = string.IsNullOrEmpty(root) ? string.Empty : root; - - foreach (var segment in segments) - { - if (segment.Contains('*') || segment.Contains('?')) - { - break; - } - - current = string.IsNullOrEmpty(current) - ? segment - : Path.Combine(current, segment); - } - - return string.IsNullOrEmpty(current) ? Directory.GetCurrentDirectory() : current; - } - - private static Regex BuildGlobRegex(string relativePattern) - { - var pattern = relativePattern.Replace('\\', '/'); - var regexPattern = "^"; - - for (var index = 0; index < pattern.Length; index++) - { - var character = pattern[index]; - - if (character == '*') - { - var hasDoubleStar = index + 1 < pattern.Length && pattern[index + 1] == '*'; - if (hasDoubleStar) - { - var followedByDirectorySeparator = index + 2 < pattern.Length && pattern[index + 2] == '/'; - regexPattern += followedByDirectorySeparator ? @"(?:.*/)?" : @".*"; - index += followedByDirectorySeparator ? 2 : 1; - continue; - } - - regexPattern += @"[^/]*"; - continue; - } - - if (character == '?') - { - regexPattern += @"[^/]"; - continue; - } - - regexPattern += Regex.Escape(character.ToString()); - } - - regexPattern += "$"; - - return new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + return filteredSuite; } } diff --git a/src/ApiTestRunner.App/wwwroot/app.js b/src/ApiTestRunner.App/wwwroot/app.js index 9c77e80..5196c44 100644 --- a/src/ApiTestRunner.App/wwwroot/app.js +++ b/src/ApiTestRunner.App/wwwroot/app.js @@ -1,10 +1,34 @@ const runButton = document.getElementById("runButton"); const refreshButton = document.getElementById("refreshButton"); +const selectAllButton = document.getElementById("selectAllButton"); +const clearAllButton = document.getElementById("clearAllButton"); +const expandSelectionButton = document.getElementById("expandSelectionButton"); +const collapseSelectionButton = document.getElementById("collapseSelectionButton"); +const expandResultsButton = document.getElementById("expandResultsButton"); +const collapseResultsButton = document.getElementById("collapseResultsButton"); +const selectionContainer = document.getElementById("selectionContainer"); +const selectionSummary = document.getElementById("selectionSummary"); +const resultsSummary = document.getElementById("resultsSummary"); const environmentContainer = document.getElementById("environmentContainer"); const environmentTemplate = document.getElementById("environmentTemplate"); const endpointTemplate = document.getElementById("endpointTemplate"); const testTemplate = document.getElementById("testTemplate"); +let suiteManifest = null; +let selectedTestIds = new Set(); +let lastRunState = null; + +const selectionExpansionState = { + environments: new Map(), + endpoints: new Map() +}; + +const resultExpansionState = { + environments: new Map(), + endpoints: new Map(), + tests: new Map() +}; + async function fetchState() { const response = await fetch("/api/dashboard/state", { cache: "no-store" }); if (!response.ok) { @@ -14,11 +38,57 @@ async function fetchState() { return response.json(); } +async function fetchManifest() { + const response = await fetch("/api/dashboard/manifest", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Manifest request failed with status ${response.status}`); + } + + return response.json(); +} + +async function initializeDashboard() { + setBusy(true); + + try { + const [manifest, state] = await Promise.all([fetchManifest(), fetchState()]); + suiteManifest = manifest; + hydrateSelection(manifest); + renderSelection(manifest); + renderState(state); + } catch (error) { + renderError(error); + } finally { + setBusy(false); + } +} + async function runSuite() { + if (!suiteManifest) { + renderError(new Error("The test manifest has not loaded yet.")); + return; + } + + const allTestIds = getAllTestIds(suiteManifest); + if (selectedTestIds.size === 0) { + renderError(new Error("Select at least one test before running the suite.")); + return; + } + setBusy(true); try { - const response = await fetch("/api/dashboard/run", { method: "POST" }); + const response = await fetch("/api/dashboard/run", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + runAll: selectedTestIds.size === allTestIds.length, + selectedTestIds: Array.from(selectedTestIds) + }) + }); + if (!response.ok) { throw new Error(`Run request failed with status ${response.status}`); } @@ -32,7 +102,159 @@ async function runSuite() { } } +function hydrateSelection(manifest) { + selectedTestIds = new Set(getAllTestIds(manifest)); + + for (const environment of manifest.environments) { + selectionExpansionState.environments.set(environment.id, true); + + for (const endpoint of environment.endpoints) { + selectionExpansionState.endpoints.set(endpoint.id, false); + } + } +} + +function renderSelection(manifest) { + selectionContainer.innerHTML = ""; + + if (!manifest || !manifest.environments || manifest.environments.length === 0) { + selectionSummary.textContent = "No tests were found in the configured YAML files."; + selectionContainer.innerHTML = "

No selectable tests were loaded.

"; + updateSelectionButtons(false); + return; + } + + const totalTestCount = manifest.totalTests; + selectionSummary.textContent = `${selectedTestIds.size} of ${totalTestCount} tests selected`; + updateSelectionButtons(true); + + for (const environment of manifest.environments) { + const environmentIds = environment.endpoints.flatMap((endpoint) => endpoint.tests.map((test) => test.id)); + const environmentNode = document.createElement("details"); + environmentNode.className = "selection-group"; + environmentNode.open = selectionExpansionState.environments.get(environment.id) ?? true; + environmentNode.addEventListener("toggle", () => { + selectionExpansionState.environments.set(environment.id, environmentNode.open); + }); + + const environmentSummary = document.createElement("summary"); + environmentSummary.className = "selection-summary-row"; + + const environmentHeader = createSelectionHeader( + environment.name, + `${environment.baseUrl} - ${environment.totalTests} tests`, + environmentIds, + toggleGroupSelection + ); + + environmentSummary.appendChild(environmentHeader); + environmentNode.appendChild(environmentSummary); + + const environmentBody = document.createElement("div"); + environmentBody.className = "selection-group-body"; + + for (const endpoint of environment.endpoints) { + const endpointIds = endpoint.tests.map((test) => test.id); + const endpointNode = document.createElement("details"); + endpointNode.className = "selection-subgroup"; + endpointNode.open = selectionExpansionState.endpoints.get(endpoint.id) ?? false; + endpointNode.addEventListener("toggle", () => { + selectionExpansionState.endpoints.set(endpoint.id, endpointNode.open); + }); + + const endpointSummary = document.createElement("summary"); + endpointSummary.className = "selection-summary-row"; + + const endpointHeader = createSelectionHeader( + endpoint.name, + `${endpoint.method} ${endpoint.path} - ${endpoint.tests.length} tests`, + endpointIds, + toggleGroupSelection + ); + + endpointSummary.appendChild(endpointHeader); + endpointNode.appendChild(endpointSummary); + + const testList = document.createElement("div"); + testList.className = "selection-test-list"; + + for (const test of endpoint.tests) { + const testRow = document.createElement("label"); + testRow.className = "selection-test"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selectedTestIds.has(test.id); + checkbox.addEventListener("change", () => toggleTestSelection(test.id, checkbox.checked)); + + const details = document.createElement("span"); + details.className = "selection-label-stack"; + details.innerHTML = `${escapeHtml(test.name)}Expected HTTP ${test.expectedStatus}`; + + testRow.appendChild(checkbox); + testRow.appendChild(details); + testList.appendChild(testRow); + } + + endpointNode.appendChild(testList); + environmentBody.appendChild(endpointNode); + } + + environmentNode.appendChild(environmentBody); + selectionContainer.appendChild(environmentNode); + } +} + +function createSelectionHeader(title, detail, childTestIds, onToggle) { + const header = document.createElement("div"); + header.className = "selection-header-row"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = childTestIds.every((testId) => selectedTestIds.has(testId)); + checkbox.indeterminate = !checkbox.checked && childTestIds.some((testId) => selectedTestIds.has(testId)); + checkbox.addEventListener("click", (event) => event.stopPropagation()); + checkbox.addEventListener("change", () => onToggle(childTestIds, checkbox.checked)); + + const labelStack = document.createElement("span"); + labelStack.className = "selection-label-stack"; + labelStack.innerHTML = `${escapeHtml(title)}${escapeHtml(detail)}`; + + header.appendChild(checkbox); + header.appendChild(labelStack); + return header; +} + +function toggleGroupSelection(testIds, isChecked) { + for (const testId of testIds) { + if (isChecked) { + selectedTestIds.add(testId); + } else { + selectedTestIds.delete(testId); + } + } + + renderSelection(suiteManifest); +} + +function toggleTestSelection(testId, isChecked) { + if (isChecked) { + selectedTestIds.add(testId); + } else { + selectedTestIds.delete(testId); + } + + renderSelection(suiteManifest); +} + +function getAllTestIds(manifest) { + return manifest.environments.flatMap((environment) => + environment.endpoints.flatMap((endpoint) => endpoint.tests.map((test) => test.id)) + ); +} + function renderState(state) { + lastRunState = state; const run = state.lastRun; document.getElementById("runStatus").textContent = buildStatusText(state); @@ -47,6 +269,8 @@ function renderState(state) { environmentContainer.innerHTML = ""; if (!run || !run.environments || run.environments.length === 0) { + resultsSummary.textContent = "Use the controls to expand or collapse the latest results."; + updateResultButtons(false); environmentContainer.innerHTML = `

No run results yet

@@ -55,10 +279,22 @@ function renderState(state) { return; } + resultsSummary.textContent = `${run.passedTests} passed, ${run.failedTests} failed across ${run.environments.length} environments.`; + updateResultButtons(true); + synchronizeResultExpansionState(run); + for (const environment of run.environments) { + const environmentKey = getResultEnvironmentKey(environment); const environmentNode = environmentTemplate.content.firstElementChild.cloneNode(true); + environmentNode.open = resultExpansionState.environments.get(environmentKey) ?? environment.failedTests > 0; + environmentNode.addEventListener("toggle", () => { + resultExpansionState.environments.set(environmentKey, environmentNode.open); + }); + environmentNode.querySelector(".environment-name").textContent = environment.name; environmentNode.querySelector(".environment-url").textContent = environment.baseUrl; + environmentNode.querySelector(".environment-stats").textContent = + `${environment.passedTests} passed, ${environment.failedTests} failed, ${environment.totalTests} total`; const environmentBadge = environmentNode.querySelector(".environment-badge"); environmentBadge.textContent = environment.failedTests === 0 ? "Passing" : "Issues"; @@ -67,7 +303,13 @@ function renderState(state) { const endpointList = environmentNode.querySelector(".endpoint-list"); for (const endpoint of environment.endpoints) { + const endpointKey = getResultEndpointKey(environment, endpoint); const endpointNode = endpointTemplate.content.firstElementChild.cloneNode(true); + endpointNode.open = resultExpansionState.endpoints.get(endpointKey) ?? !endpoint.isSuccess; + endpointNode.addEventListener("toggle", () => { + resultExpansionState.endpoints.set(endpointKey, endpointNode.open); + }); + endpointNode.querySelector(".endpoint-name").textContent = endpoint.name; endpointNode.querySelector(".endpoint-meta").textContent = `${endpoint.method} ${endpoint.requestUrl} - ${Math.round(endpoint.durationMs)} ms`; @@ -81,8 +323,14 @@ function renderState(state) { const testList = endpointNode.querySelector(".test-list"); - for (const test of endpoint.tests) { + endpoint.tests.forEach((test, testIndex) => { + const testKey = getResultTestKey(environment, endpoint, test, testIndex); const testNode = testTemplate.content.firstElementChild.cloneNode(true); + testNode.open = resultExpansionState.tests.get(testKey) ?? !test.isSuccess; + testNode.addEventListener("toggle", () => { + resultExpansionState.tests.set(testKey, testNode.open); + }); + testNode.querySelector(".test-name").textContent = test.name; const testBadge = testNode.querySelector(".test-badge"); @@ -106,7 +354,7 @@ function renderState(state) { } testList.appendChild(testNode); - } + }); endpointList.appendChild(endpointNode); } @@ -115,6 +363,51 @@ function renderState(state) { } } +function synchronizeResultExpansionState(run) { + const environmentKeys = new Set(); + const endpointKeys = new Set(); + const testKeys = new Set(); + + for (const environment of run.environments) { + const environmentKey = getResultEnvironmentKey(environment); + environmentKeys.add(environmentKey); + + if (!resultExpansionState.environments.has(environmentKey)) { + resultExpansionState.environments.set(environmentKey, environment.failedTests > 0); + } + + for (const endpoint of environment.endpoints) { + const endpointKey = getResultEndpointKey(environment, endpoint); + endpointKeys.add(endpointKey); + + if (!resultExpansionState.endpoints.has(endpointKey)) { + resultExpansionState.endpoints.set(endpointKey, !endpoint.isSuccess); + } + + endpoint.tests.forEach((test, testIndex) => { + const testKey = getResultTestKey(environment, endpoint, test, testIndex); + testKeys.add(testKey); + + if (!resultExpansionState.tests.has(testKey)) { + resultExpansionState.tests.set(testKey, !test.isSuccess); + } + }); + } + } + + pruneState(resultExpansionState.environments, environmentKeys); + pruneState(resultExpansionState.endpoints, endpointKeys); + pruneState(resultExpansionState.tests, testKeys); +} + +function pruneState(stateMap, validKeys) { + for (const key of stateMap.keys()) { + if (!validKeys.has(key)) { + stateMap.delete(key); + } + } +} + function buildStatusText(state) { if (state.isRunning) { return "Tests are running."; @@ -149,6 +442,76 @@ function setBusy(isBusy) { runButton.disabled = isBusy; refreshButton.disabled = isBusy; runButton.textContent = isBusy ? "Running..." : "Run Tests"; + updateSelectionButtons(Boolean(suiteManifest) && !isBusy); + updateResultButtons(Boolean(lastRunState?.lastRun) && !isBusy); +} + +function updateSelectionButtons(isEnabled) { + selectAllButton.disabled = !isEnabled; + clearAllButton.disabled = !isEnabled; + expandSelectionButton.disabled = !isEnabled; + collapseSelectionButton.disabled = !isEnabled; +} + +function updateResultButtons(isEnabled) { + expandResultsButton.disabled = !isEnabled; + collapseResultsButton.disabled = !isEnabled; +} + +function getResultEnvironmentKey(environment) { + return `${environment.name}|${environment.baseUrl}`; +} + +function getResultEndpointKey(environment, endpoint) { + return `${getResultEnvironmentKey(environment)}|${endpoint.method}|${endpoint.requestUrl}|${endpoint.name}`; +} + +function getResultTestKey(environment, endpoint, test, testIndex) { + return `${getResultEndpointKey(environment, endpoint)}|${test.name}|${testIndex}`; +} + +function setSelectionExpansion(isOpen) { + if (!suiteManifest) { + return; + } + + for (const environment of suiteManifest.environments) { + selectionExpansionState.environments.set(environment.id, isOpen); + + for (const endpoint of environment.endpoints) { + selectionExpansionState.endpoints.set(endpoint.id, isOpen); + } + } + + renderSelection(suiteManifest); +} + +function setResultExpansion(isOpen) { + const run = lastRunState?.lastRun; + if (!run) { + return; + } + + for (const environment of run.environments) { + resultExpansionState.environments.set(getResultEnvironmentKey(environment), isOpen); + + for (const endpoint of environment.endpoints) { + resultExpansionState.endpoints.set(getResultEndpointKey(environment, endpoint), isOpen); + + endpoint.tests.forEach((test, testIndex) => { + resultExpansionState.tests.set(getResultTestKey(environment, endpoint, test, testIndex), isOpen); + }); + } + } + + renderState(lastRunState); +} + +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); } runButton.addEventListener("click", runSuite); @@ -160,6 +523,23 @@ refreshButton.addEventListener("click", async () => { } }); -fetchState() - .then(renderState) - .catch(renderError); +selectAllButton.addEventListener("click", () => { + if (!suiteManifest) { + return; + } + + selectedTestIds = new Set(getAllTestIds(suiteManifest)); + renderSelection(suiteManifest); +}); + +clearAllButton.addEventListener("click", () => { + selectedTestIds = new Set(); + renderSelection(suiteManifest); +}); + +expandSelectionButton.addEventListener("click", () => setSelectionExpansion(true)); +collapseSelectionButton.addEventListener("click", () => setSelectionExpansion(false)); +expandResultsButton.addEventListener("click", () => setResultExpansion(true)); +collapseResultsButton.addEventListener("click", () => setResultExpansion(false)); + +initializeDashboard(); diff --git a/src/ApiTestRunner.App/wwwroot/curl-import.html b/src/ApiTestRunner.App/wwwroot/curl-import.html new file mode 100644 index 0000000..e50d174 --- /dev/null +++ b/src/ApiTestRunner.App/wwwroot/curl-import.html @@ -0,0 +1,92 @@ + + + + + + API Test Runner - cURL Import + + + +
+
+
+

Generator tool

+

cURL analyzer and YAML generator

+

Paste a cURL command to check whether the environment and endpoint already exist in the loaded YAML suite, then generate suggested YAML when they do not.

+ +
+
+ +
+
+
+

Paste cURL command

+

The first version focuses on common `curl`, `-X`, `-H`, `--data`, `--data-raw`, and `--url` patterns.

+
+
+ + + +
+ + Waiting for input. +
+
+ +
+
+
+

Paste response body

+

Paste a JSON response body to build assertion YAML from actual response fields.

+
+
+ + + +
+ + No response body parsed yet. +
+
+ +
+
+
+

Assertion builder

+

Pick a field from the parsed response, choose how it should be asserted, then include those assertions in the generated endpoint YAML.

+
+
+ +
+ + + +
+ +
+ +
+ +
+

No assertion rules added yet.

+
+
+ +
+
+ + + + diff --git a/src/ApiTestRunner.App/wwwroot/curl-import.js b/src/ApiTestRunner.App/wwwroot/curl-import.js new file mode 100644 index 0000000..3d28981 --- /dev/null +++ b/src/ApiTestRunner.App/wwwroot/curl-import.js @@ -0,0 +1,560 @@ +const analyzeButton = document.getElementById("analyzeButton"); +const analyzeStatus = document.getElementById("analyzeStatus"); +const parseResponseButton = document.getElementById("parseResponseButton"); +const responseStatus = document.getElementById("responseStatus"); +const addAssertionButton = document.getElementById("addAssertionButton"); +const curlInput = document.getElementById("curlInput"); +const responseBodyInput = document.getElementById("responseBodyInput"); +const assertionFieldSelect = document.getElementById("assertionFieldSelect"); +const assertionRuleSelect = document.getElementById("assertionRuleSelect"); +const assertionValueContainer = document.getElementById("assertionValueContainer"); +const assertionList = document.getElementById("assertionList"); +const analysisContainer = document.getElementById("analysisContainer"); + +const assertionRuleDefinitions = { + equals: { label: "equals", valueMode: "typed" }, + notEquals: { label: "notEquals", valueMode: "typed" }, + type: { + label: "type", + valueMode: "select", + options: ["string", "number", "boolean", "object", "array"] + }, + containsText: { label: "containsText", valueMode: "text" }, + startsWith: { label: "startsWith", valueMode: "text" }, + endsWith: { label: "endsWith", valueMode: "text" }, + notEmpty: { + label: "notEmpty", + valueMode: "select", + options: [ + { label: "true", value: true }, + { label: "false", value: false } + ] + }, + minCount: { label: "minCount", valueMode: "number" }, + maxCount: { label: "maxCount", valueMode: "number" }, + count: { label: "count", valueMode: "number" } +}; + +let parsedResponseFields = []; +let parsedResponseObject = null; +let assertionDrafts = []; + +async function analyzeCurlCommand() { + const command = curlInput.value.trim(); + if (!command) { + renderStatus("Paste a cURL command first."); + return; + } + + setBusy(true); + + try { + const response = await fetch("/api/tools/curl/analyze", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + command, + responseBody: responseBodyInput.value.trim() || null, + assertions: assertionDrafts.map((draft) => ({ + field: draft.field, + rule: draft.rule, + value: draft.value + })) + }) + }); + + if (!response.ok) { + throw new Error(`Analyze request failed with status ${response.status}`); + } + + const result = await response.json(); + renderResult(result); + renderStatus("Analysis completed."); + } catch (error) { + analysisContainer.innerHTML = ""; + renderStatus(error.message || "Unable to analyze the provided cURL command."); + } finally { + setBusy(false); + } +} + +function parseResponseBody() { + const responseBody = responseBodyInput.value.trim(); + if (!responseBody) { + parsedResponseFields = []; + parsedResponseObject = null; + assertionDrafts = []; + renderAssertionBuilder(); + responseStatus.textContent = "No response body parsed yet."; + return; + } + + try { + parsedResponseObject = JSON.parse(responseBody); + parsedResponseFields = collectResponseFields(parsedResponseObject); + assertionDrafts = []; + renderAssertionBuilder(); + responseStatus.textContent = `${parsedResponseFields.length} selectable fields detected.`; + } catch (error) { + parsedResponseFields = []; + parsedResponseObject = null; + assertionDrafts = []; + renderAssertionBuilder(); + responseStatus.textContent = error.message || "Response body is not valid JSON."; + } +} + +function collectResponseFields(value, path = "") { + const fields = []; + const type = getJsonValueType(value); + + if (path) { + fields.push({ + path, + type, + sample: value + }); + } + + if (Array.isArray(value)) { + value.forEach((item, index) => { + fields.push(...collectResponseFields(item, `${path}[${index}]`)); + }); + + return fields; + } + + if (value && typeof value === "object") { + Object.entries(value).forEach(([key, childValue]) => { + const childPath = path ? `${path}.${key}` : key; + fields.push(...collectResponseFields(childValue, childPath)); + }); + } + + return fields; +} + +function renderAssertionBuilder() { + renderFieldOptions(); + renderRuleOptions(); + renderValueInput(); + renderAssertionDrafts(); + addAssertionButton.disabled = parsedResponseFields.length === 0; +} + +function renderFieldOptions() { + assertionFieldSelect.innerHTML = ""; + + if (parsedResponseFields.length === 0) { + const option = document.createElement("option"); + option.textContent = "Parse a response body first"; + option.value = ""; + assertionFieldSelect.appendChild(option); + assertionFieldSelect.disabled = true; + return; + } + + assertionFieldSelect.disabled = false; + + for (const field of parsedResponseFields) { + const option = document.createElement("option"); + option.value = field.path; + option.textContent = `${field.path} (${field.type})`; + assertionFieldSelect.appendChild(option); + } +} + +function renderRuleOptions() { + assertionRuleSelect.innerHTML = ""; + + const field = getSelectedField(); + if (!field) { + assertionRuleSelect.disabled = true; + return; + } + + const supportedRules = getRulesForFieldType(field.type); + supportedRules.forEach((rule) => { + const option = document.createElement("option"); + option.value = rule; + option.textContent = assertionRuleDefinitions[rule].label; + assertionRuleSelect.appendChild(option); + }); + + assertionRuleSelect.disabled = false; +} + +function renderValueInput() { + assertionValueContainer.innerHTML = ""; + const field = getSelectedField(); + const rule = assertionRuleSelect.value; + + const label = document.createElement("span"); + label.textContent = "Value"; + assertionValueContainer.appendChild(label); + + if (!field || !rule) { + const input = document.createElement("input"); + input.className = "tool-input-inline"; + input.type = "text"; + input.disabled = true; + assertionValueContainer.appendChild(input); + return; + } + + const definition = assertionRuleDefinitions[rule]; + + if (definition.valueMode === "select") { + const select = document.createElement("select"); + select.id = "assertionValueInput"; + select.className = "tool-select"; + + definition.options.forEach((optionDefinition) => { + const option = document.createElement("option"); + if (typeof optionDefinition === "string") { + option.value = optionDefinition; + option.textContent = optionDefinition; + } else { + option.value = String(optionDefinition.value); + option.textContent = optionDefinition.label; + } + + select.appendChild(option); + }); + + assertionValueContainer.appendChild(select); + return; + } + + const input = document.createElement("input"); + input.id = "assertionValueInput"; + input.className = "tool-input-inline"; + + if (definition.valueMode === "number") { + input.type = "number"; + input.step = "1"; + input.value = Array.isArray(field.sample) ? String(field.sample.length) : "1"; + } else { + input.type = "text"; + input.value = definition.valueMode === "typed" ? formatSample(field.sample) : ""; + } + + assertionValueContainer.appendChild(input); +} + +function getSelectedField() { + if (parsedResponseFields.length === 0) { + return null; + } + + return parsedResponseFields.find((field) => field.path === assertionFieldSelect.value) ?? parsedResponseFields[0]; +} + +function getRulesForFieldType(fieldType) { + const commonRules = ["equals", "notEquals", "type", "notEmpty"]; + + switch (fieldType) { + case "string": + return [...commonRules, "containsText", "startsWith", "endsWith"]; + case "array": + return [...commonRules, "minCount", "maxCount", "count"]; + case "object": + return ["type", "notEmpty"]; + case "number": + case "boolean": + return ["equals", "notEquals", "type"]; + default: + return commonRules; + } +} + +function addAssertionDraft() { + const field = getSelectedField(); + const rule = assertionRuleSelect.value; + + if (!field || !rule) { + responseStatus.textContent = "Parse a response body and choose a field first."; + return; + } + + const valueInput = document.getElementById("assertionValueInput"); + const value = convertAssertionValue(rule, field.type, valueInput); + + assertionDrafts.push({ + field: field.path, + rule, + value + }); + + renderAssertionDrafts(); +} + +function convertAssertionValue(rule, fieldType, input) { + const definition = assertionRuleDefinitions[rule]; + + if (definition.valueMode === "select") { + if (rule === "notEmpty") { + return input.value === "true"; + } + + return input.value; + } + + if (definition.valueMode === "number") { + return Number.parseInt(input.value, 10); + } + + if (definition.valueMode === "text") { + return input.value; + } + + const text = input.value; + + switch (fieldType) { + case "number": + return Number(text); + case "boolean": + return text.toLowerCase() === "true"; + case "object": + case "array": + try { + return JSON.parse(text); + } catch { + return text; + } + default: + return text; + } +} + +function renderAssertionDrafts() { + assertionList.innerHTML = ""; + + if (assertionDrafts.length === 0) { + assertionList.innerHTML = "

No assertion rules added yet.

"; + return; + } + + assertionDrafts.forEach((draft, index) => { + const item = document.createElement("div"); + item.className = "assertion-draft-item"; + + const text = document.createElement("span"); + text.textContent = `${draft.field} -> ${draft.rule}: ${formatSample(draft.value)}`; + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "ghost-button inline-button"; + removeButton.textContent = "Remove"; + removeButton.addEventListener("click", () => { + assertionDrafts.splice(index, 1); + renderAssertionDrafts(); + }); + + item.appendChild(text); + item.appendChild(removeButton); + assertionList.appendChild(item); + }); +} + +function renderResult(result) { + analysisContainer.innerHTML = ""; + + analysisContainer.appendChild(renderRequestCard(result.request)); + analysisContainer.appendChild(renderEnvironmentCard(result.environment)); + analysisContainer.appendChild(renderEndpointCard(result.endpoint)); +} + +function renderRequestCard(request) { + const card = createCard("Parsed request", "What the app extracted from the cURL command."); + const details = document.createElement("dl"); + details.className = "detail-list"; + details.appendChild(createDetail("Method", request.method)); + details.appendChild(createDetail("Base URL", request.baseUrl)); + details.appendChild(createDetail("Path", request.path)); + details.appendChild(createDetail("Relative path", request.relativePath || request.path)); + details.appendChild(createDetail("URL", request.url)); + details.appendChild(createDetail("Query", request.query && Object.keys(request.query).length > 0 ? JSON.stringify(request.query, null, 2) : "(none)")); + details.appendChild(createDetail("Headers", request.headers && Object.keys(request.headers).length > 0 ? JSON.stringify(request.headers, null, 2) : "(none)")); + details.appendChild(createDetail("Body", formatSample(request.body) || request.rawBody || "(none)")); + card.appendChild(details); + return card; +} + +function renderEnvironmentCard(environment) { + const card = createCard("Environment scan", "Checks all loaded environment YAML definitions for an existing URL match before suggesting a new environment file."); + card.appendChild(createBadgeRow(environment.exists, environment.exists ? "Environment found" : "Environment missing")); + + const details = document.createElement("dl"); + details.className = "detail-list"; + details.appendChild(createDetail("Matched environments", environment.matchedEnvironmentNames.length > 0 ? environment.matchedEnvironmentNames.join(", ") : "(none)")); + details.appendChild(createDetail("Suggested environment name", environment.suggestedName)); + + if (environment.suggestedFilePath) { + details.appendChild(createDetail("Suggested file path", environment.suggestedFilePath)); + } + + card.appendChild(details); + + if (environment.suggestedYaml) { + card.appendChild(createCopyAction(environment.suggestedYaml, "Copy environment YAML")); + const preview = document.createElement("pre"); + preview.className = "code-block"; + preview.textContent = environment.suggestedYaml; + card.appendChild(preview); + } + + return card; +} + +function renderEndpointCard(endpoint) { + const card = createCard("Endpoint scan", "Checks whether the endpoint already exists, then generates endpoint YAML including any assertion rules you added."); + card.appendChild(createBadgeRow(endpoint.exists, endpoint.exists ? "Endpoint found" : "Endpoint missing")); + + const details = document.createElement("dl"); + details.className = "detail-list"; + details.appendChild(createDetail("Matched environments", endpoint.matchedEnvironmentNames.length > 0 ? endpoint.matchedEnvironmentNames.join(", ") : "(none)")); + details.appendChild(createDetail("Suggested endpoint name", endpoint.suggestedName)); + + if (endpoint.suggestedFilePath) { + details.appendChild(createDetail("Suggested file path", endpoint.suggestedFilePath)); + } + + card.appendChild(details); + + if (endpoint.suggestedYaml) { + card.appendChild(createCopyAction(endpoint.suggestedYaml, "Copy endpoint YAML")); + const preview = document.createElement("pre"); + preview.className = "code-block"; + preview.textContent = endpoint.suggestedYaml; + card.appendChild(preview); + } + + return card; +} + +function createCard(title, summary) { + const card = document.createElement("section"); + card.className = "preview-card"; + card.innerHTML = `

${escapeHtml(title)}

${escapeHtml(summary)}

`; + return card; +} + +function createBadgeRow(isPassing, text) { + const wrapper = document.createElement("div"); + wrapper.className = "badge-row"; + + const badge = document.createElement("span"); + badge.className = `status-badge ${isPassing ? "passing" : "failing"}`; + badge.textContent = text; + + wrapper.appendChild(badge); + return wrapper; +} + +function createDetail(term, description) { + const wrapper = document.createElement("div"); + + const dt = document.createElement("dt"); + dt.textContent = term; + + const dd = document.createElement("dd"); + dd.textContent = description; + + wrapper.appendChild(dt); + wrapper.appendChild(dd); + return wrapper; +} + +function createCopyAction(text, label) { + const actionRow = document.createElement("div"); + actionRow.className = "copy-action-row"; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "ghost-button inline-button copy-button"; + button.innerHTML = "📋 " + escapeHtml(label); + button.addEventListener("click", async () => { + const originalLabel = button.innerHTML; + + try { + await navigator.clipboard.writeText(text); + button.innerHTML = "✓ Copied"; + } catch { + button.innerHTML = "Copy failed"; + } + + window.setTimeout(() => { + button.innerHTML = originalLabel; + }, 1400); + }); + + actionRow.appendChild(button); + return actionRow; +} + +function formatSample(value) { + if (value === null || typeof value === "undefined") { + return ""; + } + + if (typeof value === "string") { + return value; + } + + return JSON.stringify(value, null, 2); +} + +function getJsonValueType(value) { + if (Array.isArray(value)) { + return "array"; + } + + if (value === null) { + return "null"; + } + + switch (typeof value) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + case "object": + return "object"; + default: + return "unknown"; + } +} + +function renderStatus(message) { + analyzeStatus.textContent = message; +} + +function setBusy(isBusy) { + analyzeButton.disabled = isBusy; + parseResponseButton.disabled = isBusy; + addAssertionButton.disabled = isBusy || parsedResponseFields.length === 0; + analyzeButton.textContent = isBusy ? "Analyzing..." : "Analyze Command"; +} + +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +parseResponseButton.addEventListener("click", parseResponseBody); +assertionFieldSelect.addEventListener("change", () => { + renderRuleOptions(); + renderValueInput(); +}); +assertionRuleSelect.addEventListener("change", renderValueInput); +addAssertionButton.addEventListener("click", addAssertionDraft); +analyzeButton.addEventListener("click", analyzeCurlCommand); + +renderAssertionBuilder(); diff --git a/src/ApiTestRunner.App/wwwroot/index.html b/src/ApiTestRunner.App/wwwroot/index.html index fc131df..9225c92 100644 --- a/src/ApiTestRunner.App/wwwroot/index.html +++ b/src/ApiTestRunner.App/wwwroot/index.html @@ -13,6 +13,10 @@

Embedded dashboard

YAML-driven API test runner

Runs the configured suite, evaluates JSON assertions, and keeps the latest pass/fail snapshot local.

+
@@ -20,6 +24,22 @@

YAML-driven API test runner

+
+
+
+

Test selection

+

Loading test manifest...

+
+
+ + + + +
+
+
+
+
Total tests @@ -50,48 +70,74 @@

Run status

+
+
+
+

Test results

+

Use the controls to expand or collapse the latest results.

+
+
+ + +
+
+
+
diff --git a/src/ApiTestRunner.App/wwwroot/styles.css b/src/ApiTestRunner.App/wwwroot/styles.css index a4edf8a..34bb82b 100644 --- a/src/ApiTestRunner.App/wwwroot/styles.css +++ b/src/ApiTestRunner.App/wwwroot/styles.css @@ -37,9 +37,14 @@ body { .hero, .status-panel, +.selection-panel, +.results-panel, .environment-card, .summary-card, -.empty-state { +.empty-state, +.tool-panel, +.tool-card, +.preview-card { backdrop-filter: blur(14px); background: var(--panel); border: 1px solid var(--line); @@ -83,6 +88,29 @@ h1 { max-width: 52rem; } +.page-nav { + margin-top: 1rem; + display: flex; + gap: 0.7rem; + flex-wrap: wrap; +} + +.nav-link { + text-decoration: none; + color: var(--ink); + padding: 0.5rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.62); + font-weight: 600; +} + +.nav-link-active { + background: var(--accent-soft); + color: var(--accent); + border-color: rgba(15, 118, 110, 0.2); +} + .hero-actions { display: flex; gap: 0.75rem; @@ -125,6 +153,127 @@ button:disabled { gap: 1rem; } +.selection-panel, +.results-panel, +.tool-panel, +.tool-card, +.preview-card { + margin-top: 1.5rem; + padding: 1.25rem 1.4rem; +} + +.selection-header, +.tool-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.selection-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.selection-summary, +.helper-text, +.result-summary, +.result-note { + color: var(--muted); +} + +.selection-tree { + margin-top: 1rem; + display: grid; + gap: 0.9rem; +} + +.selection-group, +.selection-subgroup { + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.68); + border-radius: 18px; + padding: 0; +} + +.selection-subgroup { + margin-top: 0.85rem; +} + +.selection-summary-row { + list-style: none; + cursor: pointer; +} + +.selection-summary-row::-webkit-details-marker { + display: none; +} + +.selection-group-body { + padding: 0 1rem 1rem; +} + +.selection-header-row, +.selection-test { + display: flex; + gap: 0.8rem; + align-items: start; +} + +.selection-header-row { + padding: 1rem; + position: relative; +} + +.selection-summary-row .selection-header-row { + padding-right: 2.6rem; +} + +.selection-summary-row .selection-header-row::after { + content: "+"; + position: absolute; + right: 1rem; + top: 1rem; + width: 1.25rem; + text-align: center; + color: var(--muted); + font-size: 1.1rem; + font-weight: 700; +} + +details[open] > .selection-summary-row .selection-header-row::after { + content: "-"; +} + +.selection-header-row input, +.selection-test input { + margin-top: 0.2rem; +} + +.selection-label-stack { + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.selection-label-stack span { + color: var(--muted); + font-size: 0.95rem; +} + +.selection-test-list { + margin-top: 0.8rem; + display: grid; + gap: 0.65rem; + padding-left: 1rem; +} + +.empty-selection { + margin: 0; + color: var(--muted); +} + .summary-card { padding: 1.2rem 1.3rem; } @@ -163,7 +312,38 @@ button:disabled { } .environment-card { + padding: 0; +} + +.environment-summary-row { + list-style: none; + cursor: pointer; padding: 1.4rem; + position: relative; +} + +.environment-summary-row::-webkit-details-marker { + display: none; +} + +.environment-summary-row::after { + content: "+"; + position: absolute; + right: 1.4rem; + top: 1.45rem; + width: 1.25rem; + text-align: center; + color: var(--muted); + font-size: 1.1rem; + font-weight: 700; +} + +details[open] > .environment-summary-row::after { + content: "-"; +} + +.environment-details { + padding: 0 1.4rem 1.4rem; } .environment-header, @@ -176,6 +356,7 @@ button:disabled { } .environment-url, +.environment-stats, .endpoint-meta, .test-status-line { color: var(--muted); @@ -190,7 +371,38 @@ button:disabled { background: var(--panel-strong); border: 1px solid var(--line); border-radius: 18px; + padding: 0; +} + +.endpoint-summary-row { + list-style: none; + cursor: pointer; padding: 1rem; + position: relative; +} + +.endpoint-summary-row::-webkit-details-marker { + display: none; +} + +.endpoint-summary-row::after { + content: "+"; + position: absolute; + right: 1rem; + top: 1rem; + width: 1.25rem; + text-align: center; + color: var(--muted); + font-size: 1.1rem; + font-weight: 700; +} + +details[open] > .endpoint-summary-row::after { + content: "-"; +} + +.endpoint-details { + padding: 0 1rem 1rem; } .test-list { @@ -200,12 +412,43 @@ button:disabled { } .test-card { - padding: 0.9rem 1rem; + padding: 0; background: white; border-radius: 16px; border: 1px solid rgba(31, 41, 51, 0.08); } +.test-summary-row { + list-style: none; + cursor: pointer; + padding: 0.9rem 1rem; + position: relative; +} + +.test-summary-row::-webkit-details-marker { + display: none; +} + +.test-summary-row::after { + content: "+"; + position: absolute; + right: 1rem; + top: 0.95rem; + width: 1.25rem; + text-align: center; + color: var(--muted); + font-size: 1.1rem; + font-weight: 700; +} + +details[open] > .test-summary-row::after { + content: "-"; +} + +.test-details { + padding: 0 1rem 1rem; +} + .assertion-list { margin: 0; padding-left: 1.2rem; @@ -272,8 +515,147 @@ button:disabled { text-align: center; } +.tool-grid { + display: grid; + gap: 1rem; +} + +.assertion-builder-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.field-stack { + display: grid; + gap: 0.45rem; +} + +.field-stack span { + font-weight: 700; +} + +.tool-input { + width: 100%; + min-height: 180px; + border: 1px solid var(--line); + border-radius: 16px; + padding: 1rem; + resize: vertical; + font: inherit; + background: rgba(255, 255, 255, 0.86); +} + +.tool-select, +.tool-input-inline { + width: 100%; + border: 1px solid var(--line); + border-radius: 14px; + padding: 0.8rem 0.9rem; + font: inherit; + background: rgba(255, 255, 255, 0.86); +} + +.tool-actions { + margin-top: 1rem; + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} + +.tool-output-grid { + margin-top: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; +} + +.detail-list { + margin: 0.8rem 0 0; + display: grid; + gap: 0.65rem; +} + +.detail-list div { + display: grid; + gap: 0.2rem; +} + +.detail-list dt { + font-weight: 700; +} + +.detail-list dd { + margin: 0; + color: var(--muted); + word-break: break-word; +} + +.code-block { + margin: 0.8rem 0 0; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + background: #fff; + border-radius: 12px; + padding: 0.9rem; + border: 1px solid rgba(31, 41, 51, 0.08); +} + +.badge-row { + display: flex; + gap: 0.7rem; + align-items: center; + flex-wrap: wrap; + margin-top: 0.75rem; +} + +.status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.4rem 0.8rem; + border-radius: 999px; + font-weight: 700; + font-size: 0.85rem; +} + +.assertion-draft-list { + margin-top: 1rem; + display: grid; + gap: 0.8rem; +} + +.assertion-draft-item { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 0.9rem 1rem; + border-radius: 16px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.68); +} + +.inline-button { + padding: 0.55rem 0.9rem; +} + +.copy-action-row { + margin-top: 0.9rem; + display: flex; + justify-content: flex-start; +} + +.copy-button { + min-width: 170px; +} + @media (max-width: 720px) { .hero, + .selection-header, + .tool-header, .status-panel, .environment-header, .endpoint-header, diff --git a/tests/ApiTestRunner.App.Tests/ApiTestRunner.App.Tests.csproj b/tests/ApiTestRunner.App.Tests/ApiTestRunner.App.Tests.csproj new file mode 100644 index 0000000..efe1db5 --- /dev/null +++ b/tests/ApiTestRunner.App.Tests/ApiTestRunner.App.Tests.csproj @@ -0,0 +1,24 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + diff --git a/tests/ApiTestRunner.App.Tests/CurlCommandAnalyzerTests.cs b/tests/ApiTestRunner.App.Tests/CurlCommandAnalyzerTests.cs new file mode 100644 index 0000000..8453a6d --- /dev/null +++ b/tests/ApiTestRunner.App.Tests/CurlCommandAnalyzerTests.cs @@ -0,0 +1,165 @@ +using ApiTestRunner.App.Models; +using ApiTestRunner.App.Services; +using ApiTestRunner.Core.Models; + +namespace ApiTestRunner.App.Tests; + +public sealed class CurlCommandAnalyzerTests +{ + [Fact] + public async Task AnalyzeAsync_DetectsExistingEndpointUsingPathTemplate() + { + var analyzer = new CurlCommandAnalyzer(new StubConfiguredTestSuiteProvider(new ApiTestSuiteDefinition + { + Environments = + [ + new EnvironmentDefinition + { + Name = "Uat", + BaseUrl = "https://api.example.com", + Endpoints = + [ + new EndpointDefinition + { + Name = "Get Customer Details", + Method = "GET", + Path = "/customers/{customerId}", + Tests = + [ + new TestDefinition { Name = "Customer lookup should succeed", ExpectedStatus = 200 } + ] + } + ] + } + ] + })); + + var result = await analyzer.AnalyzeAsync(new CurlAnalyzeRequest + { + Command = "curl --request GET \"https://api.example.com/customers/C1001\"" + }); + + Assert.NotNull(result.Request); + Assert.True(result.Environment.Exists); + Assert.True(result.Endpoint.Exists); + Assert.Contains("Uat", result.Endpoint.MatchedEnvironmentNames); + Assert.Null(result.Endpoint.SuggestedYaml); + } + + [Fact] + public async Task AnalyzeAsync_DoesNotSuggestEnvironmentWhenExistingBaseUrlContainsPathPrefix() + { + var analyzer = new CurlCommandAnalyzer(new StubConfiguredTestSuiteProvider(new ApiTestSuiteDefinition + { + Environments = + [ + new EnvironmentDefinition + { + Name = "PartnerUat", + BaseUrl = "https://api.partner.com/AccountHoldingsMgmt" + } + ] + })); + + var result = await analyzer.AnalyzeAsync(new CurlAnalyzeRequest + { + Command = "curl --request POST \"https://api.partner.com/AccountHoldingsMgmt/GetAccountList\"" + }); + + Assert.True(result.Environment.Exists); + Assert.Contains("PartnerUat", result.Environment.MatchedEnvironmentNames); + Assert.Null(result.Environment.SuggestedYaml); + Assert.Equal("/GetAccountList", result.Request?.RelativePath); + } + + [Fact] + public async Task AnalyzeAsync_GeneratesEnvironmentAndEndpointYamlWhenMissing() + { + var analyzer = new CurlCommandAnalyzer(new StubConfiguredTestSuiteProvider(new ApiTestSuiteDefinition + { + Environments = + [ + new EnvironmentDefinition + { + Name = "Local", + BaseUrl = "https://localhost:5005" + } + ] + })); + + var result = await analyzer.AnalyzeAsync(new CurlAnalyzeRequest + { + Command = """ + curl --request POST "https://api.partner.com/AccountHoldingsMgmt/GetAccountList?baseCurrency=SGD" \ + --header "Content-Type: application/json" \ + --data "{\"currentPageNumber\":1,\"recordsPerPage\":10}" + """ + }); + + Assert.False(result.Environment.Exists); + Assert.False(result.Endpoint.Exists); + Assert.NotNull(result.Environment.SuggestedYaml); + Assert.Contains("baseUrl: https://api.partner.com", result.Environment.SuggestedYaml); + Assert.NotNull(result.Endpoint.SuggestedYaml); + Assert.Contains("path: /AccountHoldingsMgmt/GetAccountList", result.Endpoint.SuggestedYaml); + Assert.Contains("baseCurrency: SGD", result.Endpoint.SuggestedYaml); + Assert.Contains("currentPageNumber: 1", result.Endpoint.SuggestedYaml); + } + + [Fact] + public async Task AnalyzeAsync_IncludesSelectedAssertionsInGeneratedYaml() + { + var analyzer = new CurlCommandAnalyzer(new StubConfiguredTestSuiteProvider(new ApiTestSuiteDefinition + { + Environments = [] + })); + + var result = await analyzer.AnalyzeAsync(new CurlAnalyzeRequest + { + Command = "curl --request POST \"https://api.partner.com/AccountHoldingsMgmt/GetAccountList\"", + Assertions = + [ + new CurlAssertionDraft + { + Field = "statusCode", + Rule = "equals", + Value = 1 + }, + new CurlAssertionDraft + { + Field = "data.pagenationTemplate.dataLists", + Rule = "minCount", + Value = 1 + }, + new CurlAssertionDraft + { + Field = "data.pagenationTemplate.dataLists", + Rule = "notEmpty", + Value = true + } + ] + }); + + Assert.NotNull(result.Endpoint.SuggestedYaml); + Assert.Contains("field: statusCode", result.Endpoint.SuggestedYaml); + Assert.Contains("equals: 1", result.Endpoint.SuggestedYaml); + Assert.Contains("field: data.pagenationTemplate.dataLists", result.Endpoint.SuggestedYaml); + Assert.Contains("minCount: 1", result.Endpoint.SuggestedYaml); + Assert.Contains("notEmpty: true", result.Endpoint.SuggestedYaml); + } + + private sealed class StubConfiguredTestSuiteProvider : IConfiguredTestSuiteProvider + { + private readonly ApiTestSuiteDefinition _suite; + + public StubConfiguredTestSuiteProvider(ApiTestSuiteDefinition suite) + { + _suite = suite; + } + + public Task LoadAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(new LoadedTestSuite(_suite, [])); + } + } +} diff --git a/tests/ApiTestRunner.App.Tests/DashboardSuiteManifestFactoryTests.cs b/tests/ApiTestRunner.App.Tests/DashboardSuiteManifestFactoryTests.cs new file mode 100644 index 0000000..c84f916 --- /dev/null +++ b/tests/ApiTestRunner.App.Tests/DashboardSuiteManifestFactoryTests.cs @@ -0,0 +1,62 @@ +using ApiTestRunner.App.Services; +using ApiTestRunner.Core.Models; + +namespace ApiTestRunner.App.Tests; + +public sealed class DashboardSuiteManifestFactoryTests +{ + [Fact] + public void Filter_KeepsOnlySelectedTestsAndRemovesEmptyEndpoints() + { + var suite = new ApiTestSuiteDefinition + { + Environments = + [ + new EnvironmentDefinition + { + Name = "Local", + BaseUrl = "https://localhost:5005", + Endpoints = + [ + new EndpointDefinition + { + Name = "Endpoint A", + Method = "GET", + Path = "/api/a", + Tests = + [ + new TestDefinition { Name = "A1", ExpectedStatus = 200 }, + new TestDefinition { Name = "A2", ExpectedStatus = 200 } + ] + }, + new EndpointDefinition + { + Name = "Endpoint B", + Method = "POST", + Path = "/api/b", + Tests = + [ + new TestDefinition { Name = "B1", ExpectedStatus = 201 } + ] + } + ] + } + ] + }; + + var selectedTestId = DashboardSuiteManifestFactory.CreateTestId( + suite.Environments[0], + suite.Environments[0].Endpoints[0], + suite.Environments[0].Endpoints[0].Tests[1], + testIndex: 1); + + var filtered = DashboardSuiteManifestFactory.Filter(suite, [selectedTestId]); + + var environment = Assert.Single(filtered.Environments); + var endpoint = Assert.Single(environment.Endpoints); + Assert.Equal("Endpoint A", endpoint.Name); + + var test = Assert.Single(endpoint.Tests); + Assert.Equal("A2", test.Name); + } +} diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll index 16913d3..7f95bf2 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb index 097f701..b015ef9 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb differ diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll index 0e4a133..221693e 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb index 5c489b9..2e4b09c 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs index 6476ed3..d8bc681 100644 --- a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs +++ b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs @@ -13,7 +13,7 @@ [assembly: System.Reflection.AssemblyCompanyAttribute("ApiTestRunner.Core.Tests")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+3d0215b5c2fdd7ea009ac312b6cea1197b65115c")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+5faea6932d2c3dfd437e14d8a50db590f9efb3eb")] [assembly: System.Reflection.AssemblyProductAttribute("ApiTestRunner.Core.Tests")] [assembly: System.Reflection.AssemblyTitleAttribute("ApiTestRunner.Core.Tests")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache index 57e0307..48c11b4 100644 --- a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache +++ b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache @@ -1 +1 @@ -1a8fdf307dd5ecbcbe5b619b2bec68cad25f6c15b0850fcc012632742c5fe3b7 +fcc2263c17cb1eec1833cd93a0efd70743bf2aeab6d8058082b3b20c52953e97 diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache index 1030cc8..d7d6a0d 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll index 16913d3..7f95bf2 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb index 097f701..b015ef9 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json index 636aca3..3929367 100644 --- a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json +++ b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json @@ -1 +1 @@ -{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/3d0215b5c2fdd7ea009ac312b6cea1197b65115c/*"}} \ No newline at end of file +{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/5faea6932d2c3dfd437e14d8a50db590f9efb3eb/*"}} \ No newline at end of file diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll index 508adf6..4ded278 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll index 508adf6..4ded278 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll differ