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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions src/ApiTestRunner.App/wwwroot/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,10 @@ function renderState(state) {
endpointBadge.textContent = endpointIsPassing ? "Pass" : "Fail";
endpointBadge.className = `endpoint-badge ${endpointIsPassing ? "passing" : "failing"}`;

endpointNode.querySelector(".response-body").textContent =
endpoint.responseBody || endpoint.errorMessage || "(empty response)";
initializeResponsePreview(
endpointNode,
endpoint.responseBody || endpoint.errorMessage || "(empty response)"
);

const testList = endpointNode.querySelector(".test-list");

Expand Down Expand Up @@ -469,6 +471,33 @@ function renderState(state) {
}
}

function initializeResponsePreview(endpointNode, responseText) {
const responseBody = endpointNode.querySelector(".response-body");
const formatButton = endpointNode.querySelector(".format-response-button");
const toggleWrapButton = endpointNode.querySelector(".toggle-response-wrap-button");

responseBody.value = formatJsonText(responseText);
responseBody.dataset.rawValue = responseText;
responseBody.wrap = "soft";
responseBody.classList.add("is-wrapped");
responseBody.classList.remove("is-unwrapped");

toggleWrapButton.innerHTML = "<i class=\"fa-solid fa-text-width button-icon\"></i>Disable Wrap";
toggleWrapButton.addEventListener("click", () => {
const isWrapped = responseBody.classList.contains("is-wrapped");
responseBody.wrap = isWrapped ? "off" : "soft";
responseBody.classList.toggle("is-wrapped", !isWrapped);
responseBody.classList.toggle("is-unwrapped", isWrapped);
toggleWrapButton.innerHTML = isWrapped
? "<i class=\"fa-solid fa-align-left button-icon\"></i>Enable Wrap"
: "<i class=\"fa-solid fa-text-width button-icon\"></i>Disable Wrap";
});

formatButton.addEventListener("click", () => {
responseBody.value = formatJsonText(responseBody.dataset.rawValue || responseBody.value);
});
}

function filterRunEnvironments(run, searchTerm) {
if (!searchTerm) {
return run.environments.map((environment) => ({
Expand Down Expand Up @@ -615,6 +644,18 @@ function formatDate(value) {
return new Date(value).toLocaleString();
}

function formatJsonText(value) {
if (typeof value !== "string") {
return String(value ?? "");
}

try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}

async function buildErrorMessage(response, fallbackMessage) {
try {
const payload = await response.json();
Expand Down
6 changes: 5 additions & 1 deletion src/ApiTestRunner.App/wwwroot/curl-import.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ <h2 class="panel-title"><i class="fa-solid fa-code panel-title-icon"></i>Paste r
</div>
</div>
<div class="card-body pt-0">
<textarea id="responseBodyInput" class="tool-input" spellcheck="false" placeholder="{&quot;statusCode&quot;:1,&quot;data&quot;:{&quot;items&quot;:[1,2,3]}}"></textarea>
<div class="tool-actions response-preview-actions">
<button id="formatResponseButton" class="ghost-button inline-button" type="button"><i class="fa-solid fa-code button-icon"></i>Format JSON</button>
<button id="toggleResponseWrapButton" class="ghost-button inline-button" type="button"><i class="fa-solid fa-text-width button-icon"></i>Disable Wrap</button>
</div>
<textarea id="responseBodyInput" class="tool-input response-preview-input is-wrapped" spellcheck="false" wrap="soft" placeholder="{&quot;statusCode&quot;:1,&quot;data&quot;:{&quot;items&quot;:[1,2,3]}}"></textarea>
<p id="responseStatus" class="result-summary mb-0">No response body parsed yet.</p>
</div>
</section>
Expand Down
34 changes: 34 additions & 0 deletions src/ApiTestRunner.App/wwwroot/curl-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const responseStatus = document.getElementById("responseStatus");
const addAssertionButton = document.getElementById("addAssertionButton");
const curlInput = document.getElementById("curlInput");
const responseBodyInput = document.getElementById("responseBodyInput");
const formatResponseButton = document.getElementById("formatResponseButton");
const toggleResponseWrapButton = document.getElementById("toggleResponseWrapButton");
const assertionFieldSelect = document.getElementById("assertionFieldSelect");
const assertionRuleSelect = document.getElementById("assertionRuleSelect");
const assertionValueContainer = document.getElementById("assertionValueContainer");
Expand Down Expand Up @@ -38,6 +40,7 @@ let parsedResponseFields = [];
let parsedResponseObject = null;
let assertionDrafts = [];
let lastParsedResponseBody = "";
let isResponseWrapped = true;

async function analyzeCurlCommand() {
const command = curlInput.value.trim();
Expand Down Expand Up @@ -120,6 +123,33 @@ function parseResponseBody() {
}
}

function formatResponseBody() {
const responseBody = responseBodyInput.value.trim();
if (!responseBody) {
renderResponseStatus("Paste a response body first.", true);
return;
}

try {
const parsed = JSON.parse(responseBody);
responseBodyInput.value = JSON.stringify(parsed, null, 2);
parseResponseBody();
renderResponseStatus("Response body formatted.", false);
} catch (error) {
renderResponseStatus(error.message || "Response body is not valid JSON.", true);
}
}

function toggleResponseWrap() {
isResponseWrapped = !isResponseWrapped;
responseBodyInput.wrap = isResponseWrapped ? "soft" : "off";
responseBodyInput.classList.toggle("is-wrapped", isResponseWrapped);
responseBodyInput.classList.toggle("is-unwrapped", !isResponseWrapped);
toggleResponseWrapButton.innerHTML = isResponseWrapped
? "<i class=\"fa-solid fa-text-width button-icon\"></i>Disable Wrap"
: "<i class=\"fa-solid fa-align-left button-icon\"></i>Enable Wrap";
}

function collectResponseFields(value, path = "") {
const fields = [];
const type = getJsonValueType(value);
Expand Down Expand Up @@ -617,6 +647,8 @@ function renderResponseStatus(message, isError) {
function setBusy(isBusy) {
analyzeButton.disabled = isBusy;
addAssertionButton.disabled = isBusy || parsedResponseFields.length === 0;
formatResponseButton.disabled = isBusy;
toggleResponseWrapButton.disabled = isBusy;
analyzeButton.innerHTML = isBusy
? "<i class=\"fa-solid fa-spinner fa-spin button-icon\"></i>Analyzing..."
: "<i class=\"fa-solid fa-wand-magic-sparkles button-icon\"></i>Analyze and Generate";
Expand Down Expand Up @@ -653,6 +685,8 @@ assertionFieldSelect.addEventListener("change", () => {
assertionRuleSelect.addEventListener("change", renderValueInput);
addAssertionButton.addEventListener("click", addAssertionDraft);
analyzeButton.addEventListener("click", analyzeCurlCommand);
formatResponseButton.addEventListener("click", formatResponseBody);
toggleResponseWrapButton.addEventListener("click", toggleResponseWrap);
responseBodyInput.addEventListener("blur", parseResponseBody);

renderAssertionBuilder();
8 changes: 7 additions & 1 deletion src/ApiTestRunner.App/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,13 @@ <h4 class="endpoint-name"></h4>
<div class="test-list"></div>
<details class="response-panel">
<summary>Response preview</summary>
<pre class="response-body"></pre>
<div class="response-panel-body">
<div class="tool-actions response-preview-actions">
<button class="ghost-button inline-button format-response-button" type="button"><i class="fa-solid fa-code button-icon"></i>Format JSON</button>
<button class="ghost-button inline-button toggle-response-wrap-button" type="button"><i class="fa-solid fa-text-width button-icon"></i>Disable Wrap</button>
</div>
<textarea class="response-body response-preview-input is-wrapped" readonly wrap="soft"></textarea>
</div>
</details>
</div>
</details>
Expand Down
61 changes: 56 additions & 5 deletions src/ApiTestRunner.App/wwwroot/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,11 @@ body.app-shell {
padding: 0 0.9rem 0.9rem;
}

.selection-group-body {
background: linear-gradient(180deg, rgba(226, 232, 240, 0.72), rgba(241, 245, 249, 0.88));
border-top: 1px solid rgba(15, 23, 42, 0.08);
}

.selection-test-list,
.endpoint-list,
.test-list {
Expand All @@ -501,8 +506,9 @@ body.app-shell {
.selection-test {
padding: 0.75rem 0.85rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.99);
border: 1px solid rgba(15, 23, 42, 0.16);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
}

.selection-test input,
Expand Down Expand Up @@ -553,6 +559,15 @@ body.app-shell {
background: rgba(255, 255, 255, 0.55);
}

.selection-summary-row {
background: linear-gradient(180deg, rgba(226, 232, 240, 0.98), rgba(203, 213, 225, 0.92));
border-bottom: 1px solid rgba(15, 23, 42, 0.12);
}

.selection-subgroup > .selection-summary-row {
background: linear-gradient(180deg, rgba(241, 245, 249, 0.98), rgba(226, 232, 240, 0.94));
}

.environment-card[open] > .environment-summary-row,
.endpoint-card[open] > .endpoint-summary-row,
.test-card[open] > .test-summary-row {
Expand Down Expand Up @@ -634,19 +649,36 @@ body.app-shell {
background: rgba(248, 250, 252, 0.88);
}

.response-panel-body {
padding: 0.85rem 0.95rem 0.95rem;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}

.response-body,
.code-block {
margin: 0;
padding: 0.85rem 0.95rem;
overflow: auto;
border-top: 1px solid rgba(15, 23, 42, 0.08);
background: #0f172a;
color: #e2e8f0;
border-radius: 0 0 0.9rem 0.9rem;
font-size: 0.81rem;
line-height: 1.45;
}

.response-body {
min-height: 16rem;
width: 100%;
padding: 0.85rem 0.95rem;
border: 0;
border-radius: 0.9rem;
resize: vertical;
font-family: Consolas, "SFMono-Regular", Menlo, monospace;
}

.code-block {
padding: 0.85rem 0.95rem;
border-radius: 0 0 0.9rem 0.9rem;
}

.assertion-list {
margin: 0;
padding-left: 1.1rem;
Expand Down Expand Up @@ -690,6 +722,25 @@ body.app-shell {
line-height: 1.5;
}

.response-preview-actions {
margin-bottom: 0.8rem;
}

.response-preview-input {
min-height: 20rem;
font-size: 0.9rem;
}

.response-preview-input.is-wrapped {
white-space: pre-wrap;
word-break: break-word;
}

.response-preview-input.is-unwrapped {
white-space: pre;
overflow-x: auto;
}

.tool-input-inline,
.tool-select {
min-height: 2.9rem;
Expand Down
25 changes: 25 additions & 0 deletions src/ApiTestRunner.Core/Services/YamlTestSuiteLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ public async Task<ApiTestSuiteDefinition> LoadAsync(IEnumerable<string> filePath
var yaml = await File.ReadAllTextAsync(filePath, cancellationToken);
var document = _deserializer.Deserialize<ApiTestDocumentDefinition>(yaml) ?? new ApiTestDocumentDefinition();

if (ShouldSkipEmptyDocument(yaml, document))
{
_logger.LogWarning("Skipping empty YAML test file: {FilePath}", filePath);
continue;
}

ValidateDocumentShape(document, filePath);

foreach (var environment in document.Environments)
Expand Down Expand Up @@ -98,6 +104,25 @@ private static void ValidateDocumentShape(ApiTestDocumentDefinition document, st
}
}

private static bool ShouldSkipEmptyDocument(string yaml, ApiTestDocumentDefinition document)
{
if (document.Environments.Count > 0 || document.Endpoints.Count > 0)
{
return false;
}

if (string.IsNullOrWhiteSpace(yaml))
{
return true;
}

var lines = yaml
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim());

return lines.All(line => line.Length == 0 || line == "---" || line == "..." || line.StartsWith('#'));
}

private static void ValidateEnvironment(EnvironmentDefinition environment, string filePath)
{
if (string.IsNullOrWhiteSpace(environment.Name))
Expand Down
31 changes: 31 additions & 0 deletions tests/ApiTestRunner.Core.Tests/YamlTestSuiteLoaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,37 @@ public async Task LoadAsync_AttachesEndpointOnlyFileToSingleEnvironmentWhenTarge
Assert.True(endpoint.Tests[0].Assertions[0].EqualsValue is bool boolean && boolean);
}

[Fact]
public async Task LoadAsync_SkipsEmptyEndpointFileAndLoadsOtherConfiguredFiles()
{
var environmentFile = WriteYaml("environment.yaml", """
environments:
- name: Local
baseUrl: https://localhost:7001
""");

var emptyEndpointFile = WriteYaml("empty-endpoint.yaml", "");

var endpointFile = WriteYaml("accounts.yaml", """
targetEnvironments:
- Local

endpoints:
- name: Get Accounts
method: GET
path: /api/accounts
tests:
- name: Accounts should exist
expectedStatus: 200
""");

var suite = await _loader.LoadAsync([environmentFile, emptyEndpointFile, endpointFile]);

var environment = Assert.Single(suite.Environments);
var endpoint = Assert.Single(environment.Endpoints);
Assert.Equal("Get Accounts", endpoint.Name);
}

[Fact]
public async Task LoadAsync_ThrowsForConflictingEnvironmentBaseUrls()
{
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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+b6531e3d6db2e3250c965f806f08cd3f385db4fb")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+63041666a7071e4da20206b2c691c0ad24079ce6")]
[assembly: System.Reflection.AssemblyProductAttribute("ApiTestRunner.Core.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("ApiTestRunner.Core.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6bf5c1731d5b5f4edbe1a68420688a78362680f6b716751ae68a391bafad5036
836548df8afe09565129f3173b86cff614d1d691aea07ae448f6a0c8fbe5591e
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/b6531e3d6db2e3250c965f806f08cd3f385db4fb/*"}}
{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/63041666a7071e4da20206b2c691c0ad24079ce6/*"}}
Binary file not shown.
Binary file not shown.
Loading