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
38 changes: 38 additions & 0 deletions DebugProbe.AspNetCore.Tests/Rendering/HtmlRendererTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,44 @@ public void Details_page_renders_waterfall_section_with_ruler_and_tooltips_when_
Assert.Contains("wf-bar--error", html);
}

[Fact]
public void Details_page_renders_curl_copy_attributes_and_buttons()
{
var entry = CreateEntry();
entry.OutgoingRequests.Add(new DebugOutgoingRequest
{
Method = "PUT",
Url = "https://external-api.test/v1/update",
StatusCode = 200,
DurationMs = 120,
RequestBody = "{\"name\":\"John\"}",
RequestHeaders = new Dictionary<string, string> { ["Authorization"] = "Bearer token" }
});

var html = HtmlRenderer.RenderDetailsPage(
entry,
CreateEnvironment(),
"{\"request\":true}",
"{\"response\":true}");

// 1. Verify Incoming Request Card attributes and button
Assert.Contains("data-method=\"POST\"", html);
Assert.Contains("data-url=\"http://example.test/orders?id=10\"", html);
Assert.Contains("data-headers=\"{&quot;X-Test&quot;:&quot;yes&quot;}\"", html);
Assert.Contains("data-body=\"{&quot;request&quot;:true}\"", html);
Assert.Contains("class=\"curl-copy-btn\"", html);

// 2. Verify Outgoing Request Card attributes and button
Assert.Contains("data-method=\"PUT\"", html);
Assert.Contains("data-url=\"https://external-api.test/v1/update\"", html);
Assert.Contains("data-headers=\"{&quot;Authorization&quot;:&quot;Bearer token&quot;}\"", html);
Assert.Contains("data-body=\"{&quot;name&quot;:&quot;John&quot;}\"", html);

// 3. Verify Response Card has no curl copy button (only 2 copy curl buttons in total should exist in HTML markup)
var occurrences = (html.Length - html.Replace("class=\"curl-copy-btn\"", "").Length) / "class=\"curl-copy-btn\"".Length;
Assert.Equal(2, occurrences);
}

private static DebugEntry CreateEntry()
{
return new DebugEntry
Expand Down
49 changes: 49 additions & 0 deletions DebugProbe.AspNetCore/Assets/css/debugprobe.css
Original file line number Diff line number Diff line change
Expand Up @@ -1290,3 +1290,52 @@ pre {
padding-bottom: 4px;
}

/* =========================
cURL Copy Button & Tooltip
========================= */
.curl-copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
color: #6b7280;
cursor: pointer;
transition: all 0.15s ease;
}

.curl-copy-btn:hover {
background: #f9fafb;
border-color: #d1d5db;
color: #111827;
}

.curl-copy-btn svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}

.copied-tooltip {
position: absolute;
background: #1e293b;
color: #f8fafc;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
z-index: 1000;
pointer-events: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
transform: translate(-50%, -100%);
margin-top: -6px;
}

72 changes: 72 additions & 0 deletions DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,78 @@ function copyText(btn) {
setTimeout(() => btn.innerText = "Copy", 1500);
}

function buildCurlCommand(method, url, headers, body, isWindows) {
function escapeSingleQuote(str) {
if (!str) return "";
return str.replace(/'/g, "'\\''");
}

function escapeDoubleQuote(str) {
if (!str) return "";
return str.replace(/"/g, '\\"').replace(/%/g, '%%');
}

const quote = isWindows ? '"' : "'";
const escape = isWindows ? escapeDoubleQuote : escapeSingleQuote;

let curlCmd = `curl -X ${method.toUpperCase()} ${quote}${escape(url)}${quote}`;

// Process headers
for (const [key, value] of Object.entries(headers)) {
if (!key || !value) continue;
const trimmedVal = value.trim();
if (trimmedVal === "[REDACTED]" || trimmedVal === "") continue;

curlCmd += ` -H ${quote}${escape(key)}: ${escape(value)}${quote}`;
}

// Process body (skip if empty or truncated indicator)
if (body && body.trim() !== "" && body !== "[Body too large]") {
curlCmd += ` -d ${quote}${escape(body)}${quote}`;
}

return curlCmd;
}

function copyAsCurl(btn) {
const card = btn.closest(".trace-card");
if (!card) return;

const method = card.dataset.method;
const url = card.dataset.url;
if (!method || !url) return;

let headers = {};
try {
headers = JSON.parse(card.dataset.headers || '{}');
} catch (e) {
// Fallback or ignore
}

const body = card.dataset.body;

const isWindows = (navigator.platform && navigator.platform.indexOf('Win') !== -1) ||
(navigator.userAgent && navigator.userAgent.indexOf('Win') !== -1);

const curlCmd = buildCurlCommand(method, url, headers, body, isWindows);

navigator.clipboard.writeText(curlCmd);

// Show temporary "Copied!" tooltip
const tooltip = document.createElement("div");
tooltip.className = "copied-tooltip";
tooltip.textContent = "Copied!";
document.body.appendChild(tooltip);

const rect = btn.getBoundingClientRect();
tooltip.style.left = (rect.left + window.scrollX + rect.width / 2) + "px";
tooltip.style.top = (rect.top + window.scrollY) + "px";

setTimeout(() => {
tooltip.remove();
}, 1500);
}


const clearBtn = document.getElementById("clearBtn");
if (clearBtn) {
Expand Down
51 changes: 47 additions & 4 deletions DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ public static string RenderDetailsPage(DebugEntry x, DebugEnvironment e, string
BuildPayloadSection("URL", string.IsNullOrWhiteSpace(x.RequestUrl) ? pathWithQuery : x.RequestUrl, "url"),
BuildHeaderSection("Headers", x.RequestHeaders),
BuildPayloadSection("Body", req, "body")
]);
],
dataMethod: x.Method,
dataUrl: string.IsNullOrWhiteSpace(x.RequestUrl) ? pathWithQuery : x.RequestUrl,
dataHeaders: System.Text.Json.JsonSerializer.Serialize(x.RequestHeaders),
dataBody: x.RequestBody);

var incomingResponse = BuildTraceCard(
"Final Response",
Expand Down Expand Up @@ -189,7 +193,11 @@ private static string BuildOutgoingRequestCard(DebugOutgoingRequest request)
statusCode: request.StatusCode,
statusText: request.StatusCode.HasValue ? null : "Failed",
durationMs: request.DurationMs,
details: details);
details: details,
dataMethod: request.Method,
dataUrl: request.Url,
dataHeaders: System.Text.Json.JsonSerializer.Serialize(request.RequestHeaders),
dataBody: request.RequestBody);
}

private static string BuildWaterfallSection(DebugEntry entry)
Expand Down Expand Up @@ -280,7 +288,19 @@ private static string BuildWaterfallSection(DebugEntry entry)
</article>";
}

private static string BuildTraceCard(string label, string method, string target, string classes, IEnumerable<string> details, int? statusCode = null, string? statusText = null, long? durationMs = null)
private static string BuildTraceCard(
string label,
string method,
string target,
string classes,
IEnumerable<string> details,
int? statusCode = null,
string? statusText = null,
long? durationMs = null,
string? dataMethod = null,
string? dataUrl = null,
string? dataHeaders = null,
string? dataBody = null)
{
var targetHost = GetDisplayTarget(target);
var status = statusCode.HasValue
Expand All @@ -292,8 +312,30 @@ private static string BuildTraceCard(string label, string method, string target,

var methodPill = !string.IsNullOrWhiteSpace(method) ? $@"<span class=""method-pill"">{Encode(method)}</span>" : "";

var dataAttrs = "";
if (!string.IsNullOrWhiteSpace(dataMethod)) dataAttrs += $" data-method=\"{Encode(dataMethod)}\"";
if (!string.IsNullOrWhiteSpace(dataUrl)) dataAttrs += $" data-url=\"{Encode(dataUrl)}\"";
if (!string.IsNullOrWhiteSpace(dataHeaders)) dataAttrs += $" data-headers=\"{Encode(dataHeaders)}\"";
if (!string.IsNullOrWhiteSpace(dataBody)) dataAttrs += $" data-body=\"{Encode(dataBody)}\"";

var copyCurlBtn = "";
if (!string.IsNullOrWhiteSpace(dataMethod))
{
copyCurlBtn = $@"
<button class=""curl-copy-btn""
type=""button""
title=""Copy as cURL""
aria-label=""Copy as cURL""
onclick=""copyAsCurl(this)"">
<svg viewBox=""0 0 24 24"" aria-hidden=""true"">
<path d=""M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2""></path>
<rect x=""8"" y=""2"" width=""8"" height=""4"" rx=""1"" ry=""1""></rect>
</svg>
</button>";
}

return $@"
<article class=""trace-card {Encode(classes)}"">
<article class=""trace-card {Encode(classes)}""{dataAttrs}>
<div class=""trace-card-main"">
<div class=""trace-card-header"">
<div class=""trace-card-title"">
Expand All @@ -305,6 +347,7 @@ private static string BuildTraceCard(string label, string method, string target,
<div class=""trace-card-meta"">
{status}
{duration}
{copyCurlBtn}
</div>
</div>
<div class=""trace-details"">
Expand Down
Loading