Skip to content
Open
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
37 changes: 37 additions & 0 deletions acceptance/dashboard_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,43 @@ func (dp *DashboardPage) ClickFirstEvent() {
dp.WaitForEventDetails(5000)
}

// GrantClipboardPermissions allows the page to read from and write to the clipboard,
// which is required for asserting on copy-to-clipboard interactions.
func (dp *DashboardPage) GrantClipboardPermissions() {
dp.t.Helper()

err := dp.Page.Context().GrantPermissions([]string{"clipboard-read", "clipboard-write"})
require.NoError(dp.t, err, "failed to grant clipboard permissions")
}

// ClickCopyQueryButton clicks the "Copy with values" button in the DB query details.
func (dp *DashboardPage) ClickCopyQueryButton() {
dp.t.Helper()

copyButton := dp.Page.Locator("button:has-text('Copy with values')")
err := copyButton.WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateVisible,
Timeout: playwright.Float(5000),
})
require.NoError(dp.t, err, "failed to wait for copy query button")

err = copyButton.Click()
require.NoError(dp.t, err, "failed to click copy query button")
}

// GetClipboardText returns the current clipboard contents.
// GrantClipboardPermissions must have been called first.
func (dp *DashboardPage) GetClipboardText() string {
dp.t.Helper()

clipboard, err := dp.Page.Evaluate("() => navigator.clipboard.readText()")
require.NoError(dp.t, err, "failed to read clipboard")

text, ok := clipboard.(string)
require.True(dp.t, ok, "clipboard content should be a string")
return text
}

// ClickFirstChildEvent clicks on the first child event (nested inside a parent event).
func (dp *DashboardPage) ClickFirstChildEvent() {
dp.t.Helper()
Expand Down
20 changes: 20 additions & 0 deletions acceptance/event_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,26 @@ func TestDBQueryEvent(t *testing.T) {
assert.Contains(t, text, "/api/db")
})
})

t.Run("copies query with interpolated values to clipboard", func(t *testing.T) {
t.Parallel()
WithTestFixtures(t, func(t *testing.T, f *TestFixtures) {
f.Dashboard.GrantClipboardPermissions()

f.Dashboard.StartCapture("global")
f.Dashboard.FetchAPI("/api/db")
f.Dashboard.WaitForEventCount(1, 5000)

// The DB query is captured as a child of the HTTP server event.
f.Dashboard.ClickFirstChildEvent()

f.Dashboard.ClickCopyQueryButton()

clipboardText := f.Dashboard.GetClipboardText()
assert.Contains(t, clipboardText, "'1'", "clipboard should contain the substituted argument value")
assert.NotContains(t, clipboardText, "$1", "clipboard should not contain the unresolved placeholder")
})
})
}

// TestLogEvent tests structured logging (slog) event capture.
Expand Down
10 changes: 6 additions & 4 deletions acceptance/testapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ func NewTestApp(t *testing.T) *TestApp {
// Test endpoint: simulates DB query (DB query event)
mux.HandleFunc("GET /api/db", func(w http.ResponseWriter, r *http.Request) {
// Simulate a DB query event
interpolatedQuery := "SELECT * FROM users WHERE id = '1'"
collectDBQuery(r.Context(), collector.DBQuery{
Query: "SELECT * FROM users WHERE id = $1",
Args: []driver.NamedValue{{Ordinal: 1, Value: 1}},
Duration: 5 * time.Millisecond,
Language: "postgresql",
Query: "SELECT * FROM users WHERE id = $1",
Args: []driver.NamedValue{{Ordinal: 1, Value: 1}},
InterpolatedQuery: &interpolatedQuery,
Duration: 5 * time.Millisecond,
Language: "postgresql",
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
Expand Down
7 changes: 7 additions & 0 deletions collector/db_query_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ type DBQuery struct {
Query string
// Args of the query or statement
Args []driver.NamedValue
// InterpolatedQuery is an optional standalone SQL statement with the Args
// already substituted into the placeholders, ready to be pasted into a
// database UI.
InterpolatedQuery *string
// Duration of executing the query or statement
Duration time.Duration
// Timestamp when the query or statement was started
Expand All @@ -28,6 +32,9 @@ type DBQuery struct {
func (q DBQuery) Size() uint64 {
size := uint64(100) // base struct overhead
size += uint64(len(q.Query))
if q.InterpolatedQuery != nil {
size += uint64(len(*q.InterpolatedQuery))
}
size += uint64(len(q.Language))
// Calculate actual size of arguments using reflection
for _, arg := range q.Args {
Expand Down
37 changes: 33 additions & 4 deletions dashboard/views/event-details.templ
Original file line number Diff line number Diff line change
Expand Up @@ -439,10 +439,22 @@ templ LogRecordDetails(event *collector.Event, record slog.Record) {
templ DBQueryDetails(event *collector.Event, query collector.DBQuery) {
<div class="p-4">
<div class="mb-4">
<h3 class="text-lg font-semibold mb-2">Database Query</h3>
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-semibold">Database Query</h2>
if query.InterpolatedQuery != nil {
<button
type="button"
data-copy-query
class={ buttonClasses(ButtonProps{Variant: ButtonVariantOutline, Size: ButtonSizeSm}) }
>Copy with values</button>
}
</div>
<div class="bg-neutral-50 p-4 rounded">
<pre id="db-query" class="whitespace-pre-wrap break-all">{ query.Query }</pre>
</div>
if query.InterpolatedQuery != nil {
<pre id="db-query-interpolated" class="hidden">{ *query.InterpolatedQuery }</pre>
}
</div>

if len(query.Args) > 0 {
Expand Down Expand Up @@ -485,11 +497,28 @@ templ DBQueryDetails(event *collector.Event, query collector.DBQuery) {
</div>
<script>
(function() {
var language = {{ query.Language }} || "sql";

var queryContent = document.getElementById('db-query');
var output = sqlFormatter.format(queryContent.textContent, {
language: {{ query.Language }} || "sql",
queryContent.textContent = sqlFormatter.format(queryContent.textContent, {
language: language,
});
queryContent.textContent = output;

var copyBtn = document.querySelector('[data-copy-query]');
var interpolated = document.getElementById('db-query-interpolated');
if (copyBtn && interpolated) {
copyBtn.addEventListener('click', function() {
var sql = interpolated.textContent;
try {
sql = sqlFormatter.format(sql, { language: language });
} catch (e) { /* fall back to the unformatted statement */ }
navigator.clipboard.writeText(sql).then(function() {
var original = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
setTimeout(function() { copyBtn.textContent = original; }, 1500);
});
});
}
})();
</script>
}
Expand Down
Loading
Loading