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
834 lines
27 KiB
Go
834 lines
27 KiB
Go
//go:build browser
|
|
|
|
// This test needs a headless browser, so it is built only with the
|
|
// browser build tag: `make test` leaves it out, and `make test-browser`
|
|
// runs it in the browser image that Dockerfile.browser pins.
|
|
|
|
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"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"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
const (
|
|
// browserTimeout bounds everything one test does in the browser.
|
|
browserTimeout = 60 * time.Second
|
|
|
|
// settleTimeout bounds the wait for an element to show or hide.
|
|
settleTimeout = 5 * time.Second
|
|
|
|
// The window size of a phone, narrow enough that the pages show
|
|
// the mobile menu button instead of the navigation links.
|
|
phoneWidth = 390
|
|
phoneHeight = 844
|
|
|
|
// olderBody is the body of the event received before the newest.
|
|
olderBody = "the older event"
|
|
)
|
|
|
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
|
// event log in a headless browser, served by the real router and so
|
|
// under the real Content-Security-Policy, and checks that the pages'
|
|
// Alpine.js directives and the copy control work.
|
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, problems := startBrowser(t)
|
|
|
|
env := newTestEnv(t)
|
|
srv := httptest.NewServer(env.router)
|
|
t.Cleanup(srv.Close)
|
|
|
|
userID, _ := env.seedUser(t, "browser", "browser-password")
|
|
webhook := env.seedWebhook(t, userID)
|
|
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
|
&database.Entrypoint{
|
|
WebhookID: webhook.ID,
|
|
Path: "3c9e1f7a-5b2d-4e8a-9f6c-2a7d1e4b8c05",
|
|
Active: true,
|
|
},
|
|
).Error)
|
|
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)
|
|
|
|
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
|
&database.DeliveryResult{
|
|
DeliveryID: dlv.ID,
|
|
AttemptNum: 1,
|
|
StatusCode: http.StatusBadGateway,
|
|
},
|
|
).Error)
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
|
))
|
|
|
|
page := srv.URL + "/hook/" + webhook.ID
|
|
|
|
checkAddEntrypoint(ctx, t, page)
|
|
|
|
// Each target type, with the fields its add target form submits, in
|
|
// page order. Only http and slack have a url field.
|
|
targetTypes := []struct {
|
|
name string
|
|
fields string
|
|
values map[string]string
|
|
}{
|
|
{
|
|
"http", "csrf_token name type url headers timeout max_retries",
|
|
map[string]string{"url": publicTargetURL},
|
|
},
|
|
{
|
|
"slack", "csrf_token name type url max_retries",
|
|
map[string]string{"url": publicTargetURL},
|
|
},
|
|
{
|
|
"database", "csrf_token name type expiry",
|
|
map[string]string{"expiry": "720h"},
|
|
},
|
|
{"log", "csrf_token name type", nil},
|
|
}
|
|
|
|
for _, tt := range targetTypes {
|
|
checkAddTarget(
|
|
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
|
|
)
|
|
}
|
|
|
|
checkRefusedTarget(ctx, t, page)
|
|
checkCopy(ctx, t, page)
|
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
|
checkRecentEvents(ctx, t, page)
|
|
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)
|
|
|
|
assert.Empty(t, problems(), "the browser reported problems")
|
|
}
|
|
|
|
// startBrowser starts a headless browser for one test. It returns the
|
|
// context that drives it, and a function listing what the browser
|
|
// reported going wrong on its pages: console warnings and errors,
|
|
// which is how Alpine.js reports an expression it cannot run; uncaught
|
|
// exceptions; and every entry in the browser's own security log, which
|
|
// is where it reports each script, style, image or request the
|
|
// Content-Security-Policy refused.
|
|
//
|
|
// The browser library finds the browser on PATH. Without one the first
|
|
// chromedp.Run fails, and with it the test.
|
|
func startBrowser(t *testing.T) (context.Context, func() []string) {
|
|
t.Helper()
|
|
|
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
|
|
t.Context(),
|
|
append(
|
|
chromedp.DefaultExecAllocatorOptions[:],
|
|
// Dockerfile.browser runs the test as root, where the
|
|
// browser's sandbox cannot start.
|
|
chromedp.NoSandbox,
|
|
)...,
|
|
)
|
|
t.Cleanup(cancelAlloc)
|
|
|
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
|
t.Cleanup(cancel)
|
|
|
|
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
|
|
t.Cleanup(cancelTimeout)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
problems []string
|
|
)
|
|
|
|
chromedp.ListenTarget(ctx, func(ev any) {
|
|
var problem string
|
|
|
|
switch ev := ev.(type) {
|
|
case *runtime.EventConsoleAPICalled:
|
|
if ev.Type != runtime.APITypeWarning &&
|
|
ev.Type != runtime.APITypeError {
|
|
return
|
|
}
|
|
|
|
args := make([]string, 0, len(ev.Args))
|
|
for _, arg := range ev.Args {
|
|
args = append(args, string(arg.Value))
|
|
}
|
|
|
|
problem = strings.Join(args, " ")
|
|
case *runtime.EventExceptionThrown:
|
|
problem = ev.ExceptionDetails.Error()
|
|
case *log.EventEntryAdded:
|
|
if ev.Entry.Source != log.SourceSecurity {
|
|
return
|
|
}
|
|
|
|
problem = ev.Entry.Text
|
|
default:
|
|
return
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
problems = append(problems, problem)
|
|
})
|
|
|
|
return ctx, func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
return slices.Clone(problems)
|
|
}
|
|
}
|
|
|
|
// setCookies gives the browser the cookies for the server at base.
|
|
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
|
|
return chromedp.ActionFunc(func(ctx context.Context) error {
|
|
for _, c := range cookies {
|
|
err := network.SetCookie(c.Name, c.Value).
|
|
WithURL(base).
|
|
Do(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("set cookie %s: %w", c.Name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// loadPage opens url and waits for Alpine.js to start, which it does
|
|
// by removing every x-cloak attribute. Until then x-cloak hides the
|
|
// elements Alpine would hide, so a check made earlier proves nothing.
|
|
func loadPage(url string) chromedp.Tasks {
|
|
return chromedp.Tasks{
|
|
chromedp.Navigate(url),
|
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
}
|
|
}
|
|
|
|
// shown waits up to settleTimeout for the elements matching a CSS
|
|
// selector or an XPath expression to be rendered, and reports whether
|
|
// they were. The wait is needed because Alpine.js shows an element on
|
|
// the next animation frame, not at once.
|
|
func shown(ctx context.Context, selector string) bool {
|
|
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
defer cancel()
|
|
|
|
return chromedp.Run(
|
|
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
|
|
) == nil
|
|
}
|
|
|
|
// hidden is shown's opposite: it waits for the elements to be hidden.
|
|
func hidden(ctx context.Context, selector string) bool {
|
|
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
defer cancel()
|
|
|
|
return chromedp.Run(
|
|
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
|
|
) == nil
|
|
}
|
|
|
|
// click clicks the element matching an XPath expression.
|
|
func click(ctx context.Context, t *testing.T, xpath string) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Click(xpath, chromedp.BySearch),
|
|
))
|
|
}
|
|
|
|
// checkAddEntrypoint loads a webhook page and checks that the add
|
|
// entrypoint form stays hidden until the Add button beside its heading
|
|
// is clicked. The click looks for a button element there, so it also
|
|
// checks that Add is one.
|
|
func checkAddEntrypoint(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
form := `form[action$="/entrypoints"]`
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, hidden(ctx, form),
|
|
"the add entrypoint form shows before Add is clicked")
|
|
|
|
click(ctx, t, `//h2[text()="Entrypoints"]/following-sibling::button`)
|
|
|
|
assert.True(t, shown(ctx, form),
|
|
"the add entrypoint form stays hidden when Add is clicked")
|
|
}
|
|
|
|
// publicTargetURL is a destination the server accepts for an http or
|
|
// slack target. It is a literal public address, so accepting it needs
|
|
// no DNS.
|
|
const publicTargetURL = "https://93.184.216.34/hook"
|
|
|
|
// The parts of the targets section's add target form the checks below
|
|
// find and click. Add is the button beside the Targets heading; each
|
|
// Cancel is found from the button beside it, since both are on the
|
|
// page at once.
|
|
const (
|
|
addTarget = `//h2[text()="Targets"]/following-sibling::button`
|
|
typeSelect = `//select[@aria-label="Target type"]`
|
|
nextButton = `//button[text()="Next"]`
|
|
cancelChoice = nextButton + `/following-sibling::button[text()="Cancel"]`
|
|
saveButton = `//form[contains(@action, "/targets")]//button[text()="Save"]`
|
|
cancelFields = saveButton + `/following-sibling::button[text()="Cancel"]`
|
|
targetName = `form[action$="/targets"] input[name="name"]`
|
|
submittedKeys = `[...new FormData(
|
|
document.querySelector('form[action$="/targets"]')).keys()]`
|
|
)
|
|
|
|
// checkAddTarget loads a webhook page and walks the add target form for
|
|
// one target type. The form shows nothing until Add is clicked; Add
|
|
// shows only the type choice; Cancel there closes it; Next shows the
|
|
// type's own fields in place of the choice, and the form then submits
|
|
// exactly fields, so a field another type uses, such as url, is absent;
|
|
// Cancel closes it again. It then adds a target of the type, filling in
|
|
// values, and checks that the section lists it with that type.
|
|
func checkAddTarget(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
url, targetType string,
|
|
fields []string,
|
|
values map[string]string,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.Truef(t, hidden(ctx, typeSelect),
|
|
"%s: the type choice shows before Add is clicked", targetType)
|
|
assert.Truef(t, hidden(ctx, targetName),
|
|
"%s: the fields show before Add is clicked", targetType)
|
|
|
|
click(ctx, t, addTarget)
|
|
assert.Truef(t, shown(ctx, typeSelect),
|
|
"%s: Add does not show the type choice", targetType)
|
|
assert.Truef(t, hidden(ctx, targetName),
|
|
"%s: Add shows the fields before Next", targetType)
|
|
|
|
click(ctx, t, cancelChoice)
|
|
assert.Truef(t, hidden(ctx, typeSelect),
|
|
"%s: Cancel does not close the type choice", targetType)
|
|
|
|
chooseTargetType(ctx, t, targetType)
|
|
|
|
var submitted []string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Evaluate(submittedKeys, &submitted),
|
|
))
|
|
assert.Equalf(t, fields, submitted,
|
|
"%s: the form does not submit exactly the type's fields", targetType)
|
|
|
|
click(ctx, t, cancelFields)
|
|
assert.Truef(t, hidden(ctx, targetName),
|
|
"%s: Cancel does not close the fields", targetType)
|
|
assert.Truef(t, shown(ctx, addTarget),
|
|
"%s: Add does not come back after Cancel", targetType)
|
|
|
|
name := "added-" + targetType
|
|
|
|
chooseTargetType(ctx, t, targetType)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SetValue(targetName, name, chromedp.ByQuery),
|
|
))
|
|
|
|
for field, value := range values {
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
|
`form[action$="/targets"] [name="`+field+`"]`, value,
|
|
chromedp.ByQuery,
|
|
)))
|
|
}
|
|
|
|
click(ctx, t, saveButton)
|
|
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
|
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
|
|
"%s: the added target is not listed with its type", targetType)
|
|
}
|
|
|
|
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
|
// checks that the type's fields then show in place of the type choice.
|
|
func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
|
|
t.Helper()
|
|
|
|
click(ctx, t, addTarget)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SetValue(typeSelect, targetType, chromedp.BySearch),
|
|
))
|
|
click(ctx, t, nextButton)
|
|
|
|
assert.Truef(t, shown(ctx, targetName),
|
|
"%s: Next does not show the fields", targetType)
|
|
assert.Truef(t, hidden(ctx, typeSelect),
|
|
"%s: Next leaves the type choice showing", targetType)
|
|
assert.Truef(t, hidden(ctx, addTarget),
|
|
"%s: Add still shows while the form is open", targetType)
|
|
}
|
|
|
|
// checkRefusedTarget submits an http target the server refuses, a
|
|
// loopback destination, and checks that the page comes back with the
|
|
// form open on the http fields, the values entered and the reason, and
|
|
// that after Cancel the next Add starts with an empty form and no
|
|
// reason.
|
|
func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
const (
|
|
refusedURL = "http://127.0.0.1/hook"
|
|
urlField = `form[action$="/targets"] input[name="url"]`
|
|
reason = `//div[@class="alert-error"]`
|
|
)
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
chooseTargetType(ctx, t, "http")
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
|
|
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
|
|
))
|
|
|
|
click(ctx, t, saveButton)
|
|
|
|
assert.True(t, shown(ctx, reason),
|
|
"a refused target does not show the reason")
|
|
|
|
var name, typed string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
|
))
|
|
|
|
assert.Equal(t, "refused", name,
|
|
"a refused target does not keep the name entered")
|
|
assert.Equal(t, refusedURL, typed,
|
|
"a refused target does not keep the url entered")
|
|
assert.True(t, shown(ctx, targetName),
|
|
"a refused target does not come back with the form open")
|
|
assert.True(t, hidden(ctx, typeSelect),
|
|
"a refused target comes back on the type choice")
|
|
|
|
click(ctx, t, cancelFields)
|
|
chooseTargetType(ctx, t, "http")
|
|
|
|
assert.True(t, hidden(ctx, reason),
|
|
"after Cancel, the next Add still shows the reason")
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
|
))
|
|
|
|
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
|
|
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
|
}
|
|
|
|
// checkCopy loads a webhook page and checks that the Copy control beside
|
|
// its entrypoint's URL is a button, and that clicking it copies the URL
|
|
// and says so: the button reads "Copied" only once the copy succeeded.
|
|
func checkCopy(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
copyButton := `//button[@data-copy-target]`
|
|
|
|
// A browser lets the page in its active tab write to the clipboard
|
|
// on a click. A headless browser refuses unless told to allow it.
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
browser.SetPermission(
|
|
&browser.PermissionDescriptor{Name: "clipboard-write"},
|
|
browser.PermissionSettingGranted,
|
|
),
|
|
loadPage(url),
|
|
))
|
|
|
|
click(ctx, t, copyButton)
|
|
|
|
assert.True(t, shown(ctx, copyButton+`[text()="Copied"]`),
|
|
`clicking Copy does not show "Copied"`)
|
|
}
|
|
|
|
// checkEntrypointEdit loads a webhook page whose entrypoint has no
|
|
// description, and checks that Edit shows the edit form in place of
|
|
// the description and hides until the form closes, so the form always
|
|
// opens on the saved description; that Cancel hides it and drops what
|
|
// was typed; that after typing, opening the page at elsewhere and going
|
|
// back, Edit again opens the form on the saved description; and that
|
|
// Save changes the description the page shows.
|
|
func checkEntrypointEdit(
|
|
ctx context.Context, t *testing.T, url, elsewhere string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Cancel and Save are found inside the edit form, since the add
|
|
// target form has buttons of the same names.
|
|
const (
|
|
editForm = `form[action$="/edit"]`
|
|
input = editForm + ` input[name="description"]`
|
|
cancelEdit = `//form[contains(@action, "/edit")]/button[text()="Cancel"]`
|
|
saveEdit = `//form[contains(@action, "/edit")]/button[text()="Save"]`
|
|
description = `//span[text()="Entrypoint"]`
|
|
edit = `//button[text()="Edit"]`
|
|
)
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, hidden(ctx, editForm),
|
|
"the edit form shows before Edit is clicked")
|
|
|
|
click(ctx, t, edit)
|
|
assert.True(t, shown(ctx, editForm),
|
|
"clicking Edit does not show the edit form")
|
|
assert.True(t, hidden(ctx, description),
|
|
"the description stays shown beside the edit form")
|
|
assert.True(t, hidden(ctx, edit),
|
|
"Edit stays shown while the edit form is open")
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
|
))
|
|
click(ctx, t, cancelEdit)
|
|
assert.True(t, hidden(ctx, editForm),
|
|
"clicking Cancel does not hide the edit form")
|
|
assert.True(t, shown(ctx, description),
|
|
"clicking Cancel does not show the description again")
|
|
assert.True(t, shown(ctx, edit),
|
|
"clicking Cancel does not show Edit again")
|
|
|
|
var typed string
|
|
|
|
click(ctx, t, edit)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Value(input, &typed, chromedp.ByQuery),
|
|
))
|
|
assert.Empty(t, typed, "Cancel keeps what was typed")
|
|
|
|
var loaded string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
|
loadPage(elsewhere),
|
|
chromedp.NavigateBack(),
|
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
chromedp.Evaluate(
|
|
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
|
),
|
|
))
|
|
require.Equal(
|
|
t, "back_forward", loaded,
|
|
"going back, the browser did not load the page again",
|
|
)
|
|
|
|
click(ctx, t, edit)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Value(input, &typed, chromedp.ByQuery),
|
|
))
|
|
assert.Empty(t, typed, "going back puts what was typed back in the form")
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
|
|
))
|
|
click(ctx, t, saveEdit)
|
|
|
|
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
|
|
"saving the edit form does not change the description")
|
|
}
|
|
|
|
// checkRecentEvents loads a webhook page and checks that of its recent
|
|
// events only the newest starts expanded, showing its body, that
|
|
// clicking the older one's row expands it and clicking again collapses
|
|
// it, and that clicking the newest one's row collapses it. It then
|
|
// follows the newest one's Open link to the event's own page, which
|
|
// shows the body.
|
|
func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
// The newest event's body is pretty-printed JSON. Each row's
|
|
// toggle is the button in the element that holds its state.
|
|
newest := `//pre[contains(., '"hello": "browser"')]`
|
|
older := `//pre[text()="` + olderBody + `"]`
|
|
toggle := `/ancestor::div[@x-data][1]//button`
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, shown(ctx, newest), "the newest event starts collapsed")
|
|
assert.True(t, hidden(ctx, older), "an older event starts expanded")
|
|
|
|
click(ctx, t, older+toggle)
|
|
assert.True(t, shown(ctx, older), "clicking an event does not expand it")
|
|
|
|
click(ctx, t, older+toggle)
|
|
assert.True(t, hidden(ctx, older),
|
|
"clicking an event again does not collapse it")
|
|
|
|
click(ctx, t, newest+toggle)
|
|
assert.True(t, hidden(ctx, newest),
|
|
"clicking the newest event does not collapse it")
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
click(ctx, t, newest+`/ancestor::div[@x-data][1]//a[text()="Open"]`)
|
|
|
|
assert.True(t, shown(ctx, `//h2[text()="Body"]`),
|
|
"Open does not lead to the event's own page")
|
|
assert.True(t, shown(ctx, newest),
|
|
"the event's own page does not show its body")
|
|
}
|
|
|
|
// 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.
|
|
func checkEventLog(
|
|
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// 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"]`
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
|
|
|
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")
|
|
|
|
click(ctx, t, deliveryRow)
|
|
assert.True(t, shown(ctx, attempt),
|
|
"clicking the delivery does not show its attempts")
|
|
|
|
click(ctx, t, deliveryRow)
|
|
assert.True(t, hidden(ctx, attempt),
|
|
"clicking the delivery again does not hide its attempts")
|
|
|
|
click(ctx, t, caret)
|
|
assert.True(t, hidden(ctx, expanded),
|
|
"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
|
|
// the menu button opens and closes the mobile menu.
|
|
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
// The menu button is the only button directly in the navigation
|
|
// bar's top row. Profile is a link only the mobile menu has.
|
|
button := `//nav/div/button`
|
|
menu := `//nav//a[text()="Profile"]`
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.EmulateViewport(phoneWidth, phoneHeight),
|
|
loadPage(url),
|
|
))
|
|
|
|
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
|
|
|
|
click(ctx, t, button)
|
|
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
|
|
|
|
click(ctx, t, button)
|
|
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
|
}
|