2 Commits
Author SHA1 Message Date
sneak a0b315c912 Event log: a double- or triple-click on an event's ID leaves it as it was
check / check (push) Waiting to run
The first click of a double- or triple-click comes before its selection and
toggled the event. A later click of it that finds text selected now puts the
event back as it was before the first click. The browser test checks this
with a triple-click on the ID, checks the caret and the row's aria-expanded
both while the event is expanded and while it is collapsed, and reaches the
row with Tab from the page's Back link before pressing Enter and Space. The
event log's checks are split in three to stay within the length limit.

Model: opus-5-5
2026-10-02 21:23:21 +00:00
clawbot 7402339afb Event log: an event's ID can be selected without toggling it (closes #348)
An event's row in the event log was a button element, whose text a browser
does not let be selected, and a drag over the event's ID toggled the event.
The row is now an element with the button role: focusable, toggled by Enter
and Space, and saying whether it is expanded. A click that ends a text
selection leaves the event as it is. The browser test now also clicks the
row's caret, selects the ID with the mouse, and uses the keyboard.

Model: opus-5-5
2026-10-02 21:23:21 +00:00
4 changed files with 210 additions and 22 deletions
+10 -9
View File
@@ -1356,15 +1356,16 @@ of its description and hides until the form closes, Cancel hides the form and
drops what was typed, as does leaving the page and going back to it, and Save drops what was typed, as does leaving the page and going back to it, and Save
changes the description; of the recent events on the webhook page only the changes the description; of the recent events on the webhook page only the
newest starts expanded, each expands and collapses, and Open leads to the newest starts expanded, each expands and collapses, and Open leads to the
event's own page; an event in the event log expands and collapses, and so do a event's own page; an event in the event log expands and collapses when its
delivery's attempts inside it; and at phone width the menu button opens and row's caret or its ID is clicked, and from the keyboard, but not when its ID is
closes the mobile menu. It also fails if the browser reports a console warning selected with the mouse, and a delivery's attempts inside it expand and
or error, an uncaught exception, or anything the policy refused. `make check` collapse; and at phone width the menu button opens and closes the mobile menu.
and the image build lint it but do not run it, and `make test` leaves it out It also fails if the browser reports a console warning or error, an uncaught
(its file is built only with the `browser` build tag). Run it with exception, or anything the policy refused. `make check` and the image build lint
`make test-browser` after changing `templates/` or `static/js/`: that builds it but do not run it, and `make test` leaves it out (its file is built only with
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser the `browser` build tag). Run it with `make test-browser` after changing
image, so the host needs no browser. `templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
test in a digest-pinned headless browser image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build byte as the npm registry publishes it. It is a dependency, not this repo's build
+176 -11
View File
@@ -18,10 +18,13 @@ import (
"time" "time"
"github.com/chromedp/cdproto/browser" "github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/log" "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/kb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -122,6 +125,8 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkEntrypointEdit(ctx, t, page, page+"/events") checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page) checkRecentEvents(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkEventSelection(ctx, t, page+"/events", event.ID)
checkEventKeyboard(ctx, t, page+"/events", event.ID)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems") assert.Empty(t, problems(), "the browser reported problems")
@@ -604,18 +609,26 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
"the event's own page does not show its body") "the event's own page does not show its body")
} }
// checkEventLog loads the event log and checks that clicking an event's // checkEventLog loads the event log and checks an event's row. Clicking
// row expands it, that in there clicking its delivery shows the // its ID expands the event, and in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking // delivery's attempts and clicking again hides them. Clicking the row's
// the event's row again collapses it. // caret collapses the event, clicking it again expands it, and clicking
// the ID again collapses it. While the event is expanded the row says so
// and its caret is turned up, and while it is collapsed neither.
func checkEventLog( func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string, ctx context.Context, t *testing.T, url, eventID, targetName string,
) { ) {
t.Helper() t.Helper()
// The event's row shows its ID, and its Resubmit form is in the part // The event's row shows its ID and ends with its caret, which turns
// that expands. The delivery's row there shows the target's name. // up with Tailwind's rotate-180 class, and its Resubmit form is in
eventRow := `//span[text()="` + eventID + `"]` // the part that expands. The delivery's row there shows the target's
// name.
id := `//span[text()="` + eventID + `"]`
row := id + `/ancestor::div[@role="button"]`
caret := row + `//*[local-name()="svg"]`
caretUp := caret + `[contains(@class, "rotate-180")]`
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
expanded := `form[action$="/` + eventID + `/resubmit"]` expanded := `form[action$="/` + eventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]` deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]` attempt := `//span[text()="Attempt 1"]`
@@ -624,8 +637,13 @@ func checkEventLog(
assert.True(t, hidden(ctx, expanded), "the event starts expanded") assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, eventRow) click(ctx, t, id)
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it") assert.True(t, shown(ctx, expanded),
"clicking the event's ID does not expand it")
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
"the expanded event's row does not say it is expanded")
assert.True(t, shown(ctx, caretUp),
"the expanded event's caret does not turn up")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown") assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
@@ -637,9 +655,156 @@ func checkEventLog(
assert.True(t, hidden(ctx, attempt), assert.True(t, hidden(ctx, attempt),
"clicking the delivery again does not hide its attempts") "clicking the delivery again does not hide its attempts")
click(ctx, t, eventRow) click(ctx, t, caret)
assert.True(t, hidden(ctx, expanded), assert.True(t, hidden(ctx, expanded),
"clicking the event again does not collapse it") "clicking the caret does not collapse the event")
assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`),
"the collapsed event's row does not say it is collapsed")
assert.True(t, shown(ctx, caretDown),
"the collapsed event's caret stays turned up")
click(ctx, t, caret)
assert.True(t, shown(ctx, expanded),
"clicking the caret again does not expand the event")
click(ctx, t, id)
assert.True(t, hidden(ctx, expanded),
"clicking the event's ID again does not collapse it")
}
// checkEventSelection loads the event log and checks that selecting an
// event's ID with the mouse leaves the event as it was: dragging over the
// ID leaves it collapsed, the caret's next click still expands it, and a
// triple-click on the ID then leaves it expanded.
func checkEventSelection(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
id := `//span[text()="` + eventID + `"]`
row := id + `/ancestor::div[@role="button"]`
caret := row + `//*[local-name()="svg"]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var (
selected, state string
hasState bool
)
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
selectText(ctx, t, id)
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
chromedp.AttributeValue(
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
),
))
assert.Equal(t, eventID, selected, "the event's ID cannot be selected")
require.True(t, hasState, "the event's row does not say if it is expanded")
assert.Equal(t, "false", state, "selecting the event's ID expands it")
// The caret's click also clears the selection, so the triple-click's
// first click finds nothing selected, as a person's would.
click(ctx, t, caret)
assert.True(t, shown(ctx, expanded),
"clicking the caret after selecting the ID does not expand the event")
tripleClick(ctx, t, id)
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
chromedp.AttributeValue(
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
),
))
assert.Contains(t, selected, eventID,
"a triple-click does not select the event's ID")
assert.Equal(t, "true", state,
"a triple-click selecting the event's ID collapses it")
}
// checkEventKeyboard loads the event log and checks that Tab from the
// page's Back link reaches the event's row, the first after it, and that
// Enter then expands the event and Space collapses it.
func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
back := `//a[contains(text(), "Back to")]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var focused string
require.NoError(t, chromedp.Run(
ctx,
loadPage(url),
chromedp.Focus(back, chromedp.BySearch),
chromedp.KeyEvent(kb.Tab),
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
))
require.Contains(t, focused, eventID,
"Tab from the Back link does not reach the event's row")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event")
}
// selectText selects the text of the element matching an XPath
// expression as a person does with the mouse: pressing the button at the
// text's start, moving to its end and releasing it there.
func selectText(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
var box *dom.BoxModel
require.NoError(t, chromedp.Run(
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
))
// The content box's corners, clockwise from its top left.
left, right := box.Content[0]+1, box.Content[2]-1
middle := (box.Content[1] + box.Content[5]) / 2
require.NoError(t, chromedp.Run(
ctx,
input.DispatchMouseEvent(input.MousePressed, left, middle).
WithButton(input.Left).WithButtons(1).WithClickCount(1),
input.DispatchMouseEvent(input.MouseMoved, right, middle).
WithButton(input.Left).WithButtons(1),
input.DispatchMouseEvent(input.MouseReleased, right, middle).
WithButton(input.Left).WithClickCount(1),
))
}
// tripleClick clicks three times in a row in the middle of the element
// matching an XPath expression, as a person does to select a whole line
// of text. The browser selects a word on the second click and the whole
// paragraph on the third.
func tripleClick(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
var box *dom.BoxModel
require.NoError(t, chromedp.Run(
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
))
// The content box's corners, clockwise from its top left.
x := (box.Content[0] + box.Content[2]) / 2
y := (box.Content[1] + box.Content[5]) / 2
require.NoError(t, chromedp.Run(
ctx,
chromedp.MouseClickXY(x, y, chromedp.ClickCount(1)),
chromedp.MouseClickXY(x, y, chromedp.ClickCount(2)),
chromedp.MouseClickXY(x, y, chromedp.ClickCount(3)),
))
} }
// checkMobileMenu loads a page in a phone-sized window and checks that // checkMobileMenu loads a page in a phone-sized window and checks that
+21
View File
@@ -76,12 +76,33 @@ document.addEventListener("alpine:init", function () {
window.Alpine.data("collapsible", function () { window.Alpine.data("collapsible", function () {
return { return {
open: false, open: false,
// Whether it was open before its latest single click, which
// may be the first of a double- or triple-click.
wasOpen: false,
init() { init() {
this.open = this.$root.hasAttribute("data-open"); this.open = this.$root.hasAttribute("data-open");
}, },
toggle() { toggle() {
this.open = !this.open; this.open = !this.open;
}, },
// Toggles on a click, except one that selects text, such as
// selecting an event's ID to copy it. A double- or
// triple-click selects its text on its second click, after
// its first may have toggled, so a later click that finds
// text selected puts back the state from before the first.
toggleUnlessSelecting(event) {
const selecting = window.getSelection().toString() !== "";
if (event.detail === 1) {
this.wasOpen = this.open;
}
if (!selecting) {
this.toggle();
} else if (event.detail > 1) {
this.open = this.wasOpen;
}
},
get closed() { get closed() {
return !this.open; return !this.open;
}, },
+3 -2
View File
@@ -16,7 +16,8 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
<div class="p-4" x-data="collapsible"> <div class="p-4" x-data="collapsible">
<button type="button" class="btn-small w-full flex flex-wrap justify-between gap-2 text-left" @click="toggle"> <!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<span class="flex flex-wrap items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span> <span class="text-sm font-mono text-gray-700">{{.ID}}</span>
@@ -39,7 +40,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</span> </span>
</button> </div>
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md"> <div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">