diff --git a/README.md b/README.md index 05ac3fd..006e5d1 100644 --- a/README.md +++ b/README.md @@ -1365,12 +1365,13 @@ 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 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 event's own page; an event in the -event log expands and collapses, and so do a delivery's attempts inside it; and -at phone width the menu button opens and closes the mobile menu. It also fails -if the browser reports a console warning or error, an uncaught exception, or -anything the policy refused. `make check` -and the image build lint it but do not run it, and `make test` leaves it out -(its file is built only with the `browser` build tag). Run it with +event log expands and collapses when its row's caret or its ID is clicked, and +from the keyboard, but not when its ID is selected with the mouse, and a +delivery's attempts inside it expand and collapse; and at phone width the menu +button opens and closes the mobile menu. It also fails if the browser reports a +console warning or error, an uncaught exception, or anything the policy refused. +`make check` and the image build lint it but do not run it, and `make test` +leaves it out (its file is built only with the `browser` build tag). Run it with `make test-browser` after changing `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. diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 0a13fdb..49db16a 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -18,10 +18,13 @@ import ( "time" "github.com/chromedp/cdproto/browser" + "github.com/chromedp/cdproto/dom" + "github.com/chromedp/cdproto/input" "github.com/chromedp/cdproto/log" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" + "github.com/chromedp/chromedp/kb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm/clause" @@ -40,6 +43,16 @@ const ( phoneWidth = 390 phoneHeight = 844 + // A window short enough that the event log scrolls with its last + // event expanded, and tall enough to show all of that event. + shortWidth = 1024 + shortHeight = 450 + + // A person's double- or triple-click: each press is held a tenth of a + // second, and the next press comes a quarter second after the release. + pressHeld = 100 * time.Millisecond + betweenClicks = 250 * time.Millisecond + // olderBody is the body of the event received before the newest. olderBody = "the older event" ) @@ -59,7 +72,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { t.Cleanup(srv.Close) userID, _ := env.seedUser(t, "browser", "browser-password") - webhook, event, target := seedBrowserWebhook(t, env, userID) + webhook, older, event, target := seedBrowserWebhook(t, env, userID) require.NoError(t, chromedp.Run( ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")), @@ -80,10 +93,10 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { checkCopy(ctx, t, page) checkEntrypointEdit(ctx, t, page, page+"/events") checkRecentEvents(ctx, t, page) - checkEventLog(ctx, t, page+"/events", event.ID, target.Name) checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page) checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new") checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new") + checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name) checkMobileMenu(ctx, t, page) assert.Empty(t, problems(), "the browser reported problems") @@ -91,11 +104,11 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { // seedBrowserWebhook seeds the webhook the browser test loads, owned by // userID: an entrypoint, two events, and a target whose delivery of the -// newer event failed once with a 502. It returns the webhook, the newer -// event and the target. +// newer event failed once with a 502. It returns the webhook, the older +// and the newer event, and the target. func seedBrowserWebhook( t *testing.T, env *testEnv, userID string, -) (*database.Webhook, *database.Event, *database.Target) { +) (*database.Webhook, *database.Event, *database.Event, *database.Target) { t.Helper() webhook := env.seedWebhook(t, userID) @@ -106,7 +119,7 @@ func seedBrowserWebhook( Active: true, }, ).Error) - env.seedEvent(t, webhook.ID, olderBody) + older := env.seedEvent(t, webhook.ID, olderBody) event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`) target := env.seedTarget(t, webhook.ID) dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID) @@ -121,7 +134,7 @@ func seedBrowserWebhook( }, ).Error) - return webhook, event, target + return webhook, older, event, target } // startBrowser starts a headless browser for one test. It returns the @@ -752,18 +765,30 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) { "the event's own page does not show its body") } -// checkEventLog loads the event log and checks that clicking an event's -// row expands it, that in there clicking its delivery shows the -// delivery's attempts and clicking again hides them, and that clicking -// the event's row again collapses it. +// checkEventLog loads the event log and checks an event's row. Clicking +// its ID expands the event, and in there clicking its delivery shows the +// delivery's attempts and clicking again hides them. Clicking the row's +// 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. It then +// runs checkEventSelection on the log's last event, lastEventID, and +// checkEventKeyboard on eventID. func checkEventLog( - ctx context.Context, t *testing.T, url, eventID, targetName string, + ctx context.Context, + t *testing.T, + url, eventID, lastEventID, targetName string, ) { t.Helper() - // The event's row shows its ID, and its Resubmit form is in the part - // that expands. The delivery's row there shows the target's name. - eventRow := `//span[text()="` + eventID + `"]` + // The event's row shows its ID and ends with its caret, which turns + // up with Tailwind's rotate-180 class, and its Resubmit form is in + // 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"]` deliveryRow := `//span[text()="` + targetName + `"]` attempt := `//span[text()="Attempt 1"]` @@ -772,8 +797,13 @@ func checkEventLog( assert.True(t, hidden(ctx, expanded), "the event starts expanded") - click(ctx, t, eventRow) - assert.True(t, shown(ctx, expanded), "clicking the event does not expand it") + click(ctx, t, id) + 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") @@ -785,9 +815,232 @@ func checkEventLog( assert.True(t, hidden(ctx, attempt), "clicking the delivery again does not hide its attempts") - click(ctx, t, eventRow) + click(ctx, t, caret) 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(ctx, t, url, lastEventID) + checkEventKeyboard(ctx, t, url, eventID) +} + +// checkEventSelection loads the event log in a short window and checks +// that selecting the ID of its last event, eventID, with the mouse leaves +// the event as it was, and that its caret toggles it at once. Dragging +// over the ID leaves the event collapsed, and the caret's click expands +// it at once. With the page then scrolled to its end, a double-click on +// the ID that goes on to drag along it, and a triple-click on it, each +// leave the event expanded and select that ID. Had a click there +// collapsed the event, the page would have got shorter and moved under +// the pointer before the next click. +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 + scrolled float64 + ) + + // What is selected, and whether the event's row says it is expanded. + read := chromedp.Tasks{ + chromedp.Evaluate(`window.getSelection().toString()`, &selected), + chromedp.AttributeValue( + row, "aria-expanded", &state, &hasState, chromedp.BySearch, + ), + } + + // A click on the ID toggles the event half a second after it, so a + // check that selecting the ID did not toggle it waits a second first. + settle := chromedp.Sleep(time.Second) + + // The double-click and the triple-click each start with nothing + // selected, so that their first click waits to toggle the event. + clearSelection := chromedp.Evaluate( + `window.getSelection().removeAllRanges()`, nil, + ) + + require.NoError(t, chromedp.Run( + ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url), + )) + + selectText(ctx, t, id) + require.NoError(t, chromedp.Run(ctx, settle, read)) + 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") + + click(ctx, t, caret) + require.NoError(t, chromedp.Run(ctx, read)) + assert.Equal(t, "true", state, + "clicking the caret does not expand the event at once") + + // The row says it is expanded before its expanded part is shown, so + // the scroll waits for that part, to end at the expanded page's end. + require.True(t, shown(ctx, expanded), + "clicking the caret does not show the event's expanded part") + + require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate( + `window.scrollTo(0, document.body.scrollHeight); window.scrollY`, + &scrolled, + ))) + require.Positive(t, scrolled, "the event log does not scroll") + + require.NoError(t, chromedp.Run(ctx, clearSelection)) + doubleClickAndDrag(ctx, t, id) + require.NoError(t, chromedp.Run(ctx, settle, read)) + assert.Contains(t, selected, eventID, + "a double-click and drag does not select the event's ID") + assert.Equal(t, "true", state, + "a double-click and drag over the event's ID collapses it") + + require.NoError(t, chromedp.Run(ctx, clearSelection)) + tripleClick(ctx, t, id) + require.NoError(t, chromedp.Run(ctx, settle, read)) + 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() + + left, right, y := textEnds(ctx, t, xpath) + + require.NoError(t, chromedp.Run( + ctx, press(left, y, 1), drag(right, y), release(right, y, 1), + )) +} + +// doubleClickAndDrag double-clicks the start of the text of the element +// matching an XPath expression, which selects its first word, and keeps +// the button down to drag to the text's end, which selects it word by +// word. It holds the button for a second, longer than a single click on +// an event's row waits before it toggles the event. +func doubleClickAndDrag(ctx context.Context, t *testing.T, xpath string) { + t.Helper() + + left, right, y := textEnds(ctx, t, xpath) + + require.NoError(t, chromedp.Run( + ctx, + press(left, y, 1), chromedp.Sleep(pressHeld), release(left, y, 1), + chromedp.Sleep(betweenClicks), + press(left, y, 2), drag(right, y), chromedp.Sleep(time.Second), + release(right, y, 2), + )) +} + +// tripleClick clicks three times in the middle of the text 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() + + left, right, y := textEnds(ctx, t, xpath) + x := (left + right) / 2 + + require.NoError(t, chromedp.Run( + ctx, + press(x, y, 1), chromedp.Sleep(pressHeld), release(x, y, 1), + chromedp.Sleep(betweenClicks), + press(x, y, 2), chromedp.Sleep(pressHeld), release(x, y, 2), + chromedp.Sleep(betweenClicks), + press(x, y, 3), chromedp.Sleep(pressHeld), release(x, y, 3), + )) +} + +// textEnds returns where on screen the text of the element matching an +// XPath expression starts and ends, just inside its left and right +// edges, and the height of its middle: in that order, the x of its +// start, the x of its end, and the y of both. +func textEnds( + ctx context.Context, t *testing.T, xpath string, +) (float64, float64, float64) { + 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. + return box.Content[0] + 1, box.Content[2] - 1, + (box.Content[1] + box.Content[5]) / 2 +} + +// press presses the left mouse button at x, y, as the nth click of a +// double- or triple-click. +func press(x, y float64, nth int64) *input.DispatchMouseEventParams { + return input.DispatchMouseEvent(input.MousePressed, x, y). + WithButton(input.Left).WithButtons(1).WithClickCount(nth) +} + +// drag moves the pointer to x, y with the left mouse button down. +func drag(x, y float64) *input.DispatchMouseEventParams { + return input.DispatchMouseEvent(input.MouseMoved, x, y). + WithButton(input.Left).WithButtons(1) +} + +// release releases the left mouse button at x, y, as the nth click of a +// double- or triple-click. +func release(x, y float64, nth int64) *input.DispatchMouseEventParams { + return input.DispatchMouseEvent(input.MouseReleased, x, y). + WithButton(input.Left).WithClickCount(nth) } // The parts of the new webhook page the checks below find and click. diff --git a/static/js/app.js b/static/js/app.js index 272c344..2d5fd87 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -77,12 +77,36 @@ document.addEventListener("alpine:init", function () { window.Alpine.data("collapsible", function () { return { open: false, + // The timer of the toggle a single click is waiting to make. + pendingToggle: null, init() { this.open = this.$root.hasAttribute("data-open"); }, toggle() { this.open = !this.open; }, + // Toggles on a click, except one that selects text, such as + // selecting an event's ID to copy it. A single click toggles + // only after 500 ms, the usual double-click interval, and the + // second press of a double- or triple-click cancels that (see + // cancelPendingToggle), so nothing moves under the pointer + // while it selects text. + toggleUnlessSelecting(event) { + if ( + event.detail === 1 && + window.getSelection().toString() === "" + ) { + this.pendingToggle = setTimeout(() => this.toggle(), 500); + } + }, + // Runs when the mouse button goes down, so the second press + // of a double- or triple-click cancels the toggle its first + // click is waiting to make, however long that press lasts. + cancelPendingToggle(event) { + if (event.detail > 1) { + clearTimeout(this.pendingToggle); + } + }, get closed() { return !this.open; }, diff --git a/templates/source_logs.html b/templates/source_logs.html index 08ce136..d18ae87 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -16,7 +16,8 @@