Review nits from the shared-chrome and rastrillo-path reviews
Fixes for the non-blocking findings on the merged branches (see this branch's discussion). The coordinating session checked them, on Paul's standing instruction to roll in fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from chore/review-nits into main.
7 files changed,
+359
−21
Makefile+7 −4internal/home/chrome.go+51 −13internal/home/chromefinder_test.go+103 −0internal/home/render.go+1 −1internal/home/shared_chrome_test.go+166 −0internal/home/templates/assertion.html+5 −3internal/home/zoom_browser_test.go+26 −0
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... CGO_ENABLED=0 go vet -tags browser ./internal/home/ cd handoff && CGO_ENABLED=0 go vet ./... cd roster && CGO_ENABLED=0 go vet ./... cd connect && CGO_ENABLED=0 go vet ./... === fmt === === build === CGO_ENABLED=0 go build ./... CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch cd handoff && CGO_ENABLED=0 go build ./... cd roster && CGO_ENABLED=0 go build ./... cd connect && CGO_ENABLED=0 go build ./... === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/home/cmd/home [no test files] ok amadan.net/oficina/home/cmd/home-placeholder 0.629s ? amadan.net/oficina/home/cmd/oficina [no test files] ok amadan.net/oficina/home/internal/guardstest 0.035s ok amadan.net/oficina/home/internal/home 12.244s ok amadan.net/oficina/home/internal/home/directorydata 1.118s ok amadan.net/oficina/home/internal/home/logtest 0.007s ok amadan.net/oficina/home/internal/home/seal 0.006s ok amadan.net/oficina/home/internal/home/sealtest 0.015s ok amadan.net/oficina/home/internal/operator 5.093s ok amadan.net/oficina/home/internal/operatorlaunch 4.181s ok amadan.net/oficina/home/internal/provisioner 43.266s cd handoff && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/handoff 0.326s ok amadan.net/oficina/home/handoff/seal 0.002s cd roster && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/roster 5.080s cd connect && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/connect 0.059s ok amadan.net/oficina/home/connect/linkstore 0.179s === race === go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/ ok amadan.net/oficina/home/internal/home 112.985s ok amadan.net/oficina/home/internal/operator 64.617s ok amadan.net/oficina/home/internal/operatorlaunch 22.333s ok amadan.net/oficina/home/internal/provisioner 286.786s ok amadan.net/oficina/home/internal/home/directorydata 15.930s cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/handoff 9.229s ok amadan.net/oficina/home/handoff/seal 1.009s cd roster && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/roster 6.617s cd connect && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/connect 1.633s ok amadan.net/oficina/home/connect/linkstore 3.776s
diff --git a/Makefile b/Makefile| index b3c941b..47c04de 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -126,13 +126,16 @@ test-race: |
| cd roster && CGO_ENABLED=1 go test -race -count=1 ./... |
| cd connect && CGO_ENABLED=1 go test -race -count=1 ./... |
| -# The browser checks, in a real Chromium (on PATH, RASTRILLO_CHROME, or a |
| -# Playwright cache). Deliberately NOT part of `test`: the gate CI runs on |
| +# The browser checks, in a real Chromium (RASTRILLO_CHROME, on PATH, or |
| +# the newest in a Playwright cache: Linux's ~/.cache or macOS's |
| +# ~/Library/Caches). Deliberately NOT part of `test`: the gate CI runs on |
| # every push must not need a browser. Run it before shipping a change to |
| # the CSS or to rastrillo's pin — TestNoZoomOnTap is the iOS zoom-on-tap |
| -# check. test-vet vets these files with the tag, so they keep compiling. |
| +# check. Every test in the package runs, the browser ones (the `browser` |
| +# tag) with them, so none is left out by a -run filter that predates it. |
| +# test-vet vets these files with the tag, so they keep compiling. |
| test-browser: |
| - CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap' ./internal/home/ |
| + CGO_ENABLED=0 go test -tags browser -count=1 ./internal/home/ |
| .PHONY: credits |
| credits: |
diff --git a/internal/home/chrome.go b/internal/home/chrome.go| index beb34b0..9915db0 100644 |
| --- a/internal/home/chrome.go |
| +++ b/internal/home/chrome.go |
| @@ -58,7 +58,7 @@ func (a *App) onCredential(r *http.Request) bool { |
| // sign-in, sent and signed-out pages, whose corner stays empty. hop marks |
| // a page that moves on by itself: its bar is the brand alone, as plain |
| // text, so the page still holds exactly one link, the way on. |
| -func (a *App) pageFor(r *http.Request, data any, door, hop bool) page { |
| +func (a *App) pageFor(r *http.Request, status int, data any, door, hop bool) page { |
| p := page{Data: data} |
| brand := chrome.App{Name: suiteName, Slug: "oficina", Home: "/", Icon: versioned("/static/brand/icon.svg")} |
| if hop { |
| @@ -74,7 +74,7 @@ func (a *App) pageFor(r *http.Request, data any, door, hop bool) page { |
| p.Theme = chrome.ThemeAttr(chrome.Appearance(r)) |
| p.Chrome = chrome.Bar{App: brand, Labels: barLabels} |
| if _, ok := sessions.Current(r); ok { |
| - p.Chrome.Account = a.account(r) |
| + p.Chrome.Account = a.account(r, status) |
| } else if !door { |
| p.Chrome.SignIn = &chrome.Link{Href: signInHref(r), Label: "Sign in"} |
| } |
| @@ -84,7 +84,7 @@ func (a *App) pageFor(r *http.Request, data any, door, hop bool) page { |
| // account is the directory's account menu. The directory cannot read the |
| // address (it holds a person ref, never an address: the one rule), so the |
| // identity row says only "Signed in" and the trigger is the person glyph. |
| -func (a *App) account(r *http.Request) *chrome.Account { |
| +func (a *App) account(r *http.Request, status int) *chrome.Account { |
| return &chrome.Account{ |
| TriggerLabel: "Account", |
| Labels: accountLabels, |
| @@ -93,10 +93,8 @@ func (a *App) account(r *http.Request) *chrome.Account { |
| {Href: a.origins.Credential + "/passkey/codes", Label: "Recovery codes"}, |
| }, |
| Appearance: &chrome.AppearanceMenu{ |
| - Action: "/appearance", |
| - // The path alone: no directory page reads its query, and a |
| - // query is where a crafted link would carry its text. |
| - Return: r.URL.EscapedPath(), |
| + Action: "/appearance", |
| + Return: appearanceReturn(r, status), |
| Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"), |
| }, |
| Help: []menu.Item{{Href: "/help/open-source", Label: "Open source"}}, |
| @@ -104,6 +102,20 @@ func (a *App) account(r *http.Request) *chrome.Account { |
| } |
| } |
| +// appearanceReturn is where choosing an appearance comes back to: this |
| +// page, if it is one a GET can come back to, otherwise the directory. Only |
| +// a GET that drew a 200 qualifies. A 404's or an error page's own path is |
| +// known not to work, and the path of a page drawn for a POST is POST-only, |
| +// so a 303 back to it would land on a 404. The path alone: no directory |
| +// page reads its query, and a query is where a crafted link would carry |
| +// its text. |
| +func appearanceReturn(r *http.Request, status int) string { |
| + if r.Method != http.MethodGet || status != http.StatusOK { |
| + return "/" |
| + } |
| + return r.URL.EscapedPath() |
| +} |
| + |
| // signInHref starts the directory's sign-in and comes back to this page. |
| // An error page comes back to the directory instead: its own URL is the |
| // one thing known not to exist. |
| @@ -130,10 +142,34 @@ func wantsHTML(r *http.Request) bool { |
| return strings.Contains(r.Header.Get("Accept"), "text/html") |
| } |
| -// errorCopy is estilo spec §3.2's copy for the statuses Home answers. |
| -var errorCopy = map[int]struct{ page, heading, body string }{ |
| - http.StatusNotFound: {"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."}, |
| - http.StatusInternalServerError: {"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."}, |
| +// errorWords is one status's error page: the tab title, the heading and |
| +// the line under it. |
| +type errorWords struct{ page, heading, body string } |
| + |
| +// errorCopy is estilo spec §3.2's copy for the statuses Home answers, and |
| +// rastrillo's error copy (locales/en.toml) for the rest an HTTP handler |
| +// can plausibly be asked to draw. Any other status gets errorFallback. |
| +var errorCopy = map[int]errorWords{ |
| + http.StatusBadRequest: {"Bad request", "That didn’t go through", "Something in what was sent wasn’t right. Go back and try again."}, |
| + http.StatusUnauthorized: {"Sign in needed", "You need to sign in", "This page is only for someone who is signed in. Sign in, then try again."}, |
| + http.StatusForbidden: {"No access", "You can’t see this", "Your account doesn’t have access here. If you think it should, ask whoever runs this site."}, |
| + http.StatusNotFound: {"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."}, |
| + http.StatusMethodNotAllowed: {"Not available", "That can’t be done here", "This page doesn’t take that kind of request. Go back and try again from the page itself."}, |
| + http.StatusRequestEntityTooLarge: {"Too large", "That was too large", "What was sent is bigger than this page accepts. Try again with less."}, |
| + http.StatusTooManyRequests: {"Too many tries", "Too many tries", "Wait a minute, then try again."}, |
| + http.StatusInternalServerError: {"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."}, |
| + http.StatusServiceUnavailable: {"Briefly unavailable", "We’re briefly unavailable", "Oficina is being updated or is busy. Try again in a minute."}, |
| +} |
| + |
| +// errorFallback is the error page for a status with no words of its own. |
| +var errorFallback = errorWords{"Something’s not right", "Something’s not right", "That request couldn’t be completed. Go back and try again."} |
| + |
| +// errorCopyFor is status's words, or the general ones. |
| +func errorCopyFor(status int) errorWords { |
| + if c, ok := errorCopy[status]; ok { |
| + return c |
| + } |
| + return errorFallback |
| } |
| // errorPage answers status as estilo's error page, under the bar, for a |
| @@ -145,7 +181,7 @@ func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int) { |
| http.Error(w, strings.ToLower(http.StatusText(status)), status) |
| return |
| } |
| - c := errorCopy[status] |
| + c := errorCopyFor(status) |
| home := chrome.Link{Href: "/", Label: "Go to Oficina"} |
| if a.onCredential(r) { |
| home.Href = a.origins.Directory + "/" |
| @@ -155,7 +191,9 @@ func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int) { |
| data.Ref, data.RefLabel = rastrillo.NewRef(), "Reference:" |
| a.logger.Error("error page", "ref", data.Ref, "path", r.URL.Path) |
| if r.Method == http.MethodGet { |
| - data.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| + // Beside the way home, not instead of it: the way home is the |
| + // one link known to work. |
| + data.Back = &chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| } |
| } |
| a.renderPage(w, r, status, "error.html", errorView{chrome.Title(c.page, suiteName), data}) |
diff --git a/internal/home/chromefinder_test.go b/internal/home/chromefinder_test.go| new file mode 100644 |
| index 0000000..32c1e97 |
| --- /dev/null |
| +++ b/internal/home/chromefinder_test.go |
| @@ -0,0 +1,103 @@ |
| +package home |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// playwrightChrome is the newest Chromium in a Playwright browser cache |
| +// under home, or "". rastrillo's harness looks in Linux's cache alone |
| +// (~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome), so on a Mac |
| +// `make test-browser` found nothing unless RASTRILLO_CHROME was set by |
| +// hand. The browser tests (zoom_browser_test.go) hand it this. It lives |
| +// in an untagged file so the plain suite tests the finder itself. |
| +func playwrightChrome(home string) string { |
| + var hits []string |
| + for _, layout := range []string{ |
| + // Linux, as the harness already knows it. |
| + filepath.Join(".cache", "ms-playwright", "chromium-*", "chrome-linux64", "chrome"), |
| + // macOS since Playwright moved to Chrome for Testing. |
| + filepath.Join("Library", "Caches", "ms-playwright", "chromium-*", "chrome-mac*", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"), |
| + // macOS before it. |
| + filepath.Join("Library", "Caches", "ms-playwright", "chromium-*", "chrome-mac*", "Chromium.app", "Contents", "MacOS", "Chromium"), |
| + } { |
| + m, _ := filepath.Glob(filepath.Join(home, layout)) |
| + hits = append(hits, m...) |
| + } |
| + if len(hits) == 0 { |
| + return "" |
| + } |
| + sort.SliceStable(hits, func(i, j int) bool { return chromiumRevision(hits[i]) > chromiumRevision(hits[j]) }) |
| + return hits[0] |
| +} |
| + |
| +// chromiumRevision is the N of the chromium-N directory in path, or -1. |
| +func chromiumRevision(path string) int { |
| + for _, part := range strings.Split(filepath.ToSlash(path), "/") { |
| + if rest, ok := strings.CutPrefix(part, "chromium-"); ok { |
| + if n, err := strconv.Atoi(rest); err == nil { |
| + return n |
| + } |
| + } |
| + } |
| + return -1 |
| +} |
| + |
| +func TestPlaywrightChromeFindsEveryCacheLayout(t *testing.T) { |
| + touch := func(t *testing.T, path string) { |
| + t.Helper() |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, nil, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + cft := func(home, rev, arch string) string { |
| + return filepath.Join(home, "Library", "Caches", "ms-playwright", "chromium-"+rev, "chrome-"+arch, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing") |
| + } |
| + |
| + t.Run("none", func(t *testing.T) { |
| + if got := playwrightChrome(t.TempDir()); got != "" { |
| + t.Errorf("empty home: %q", got) |
| + } |
| + }) |
| + t.Run("macOS, Chrome for Testing", func(t *testing.T) { |
| + home := t.TempDir() |
| + want := cft(home, "1234", "mac-arm64") |
| + touch(t, want) |
| + if got := playwrightChrome(home); got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| + }) |
| + t.Run("macOS, newest revision wins", func(t *testing.T) { |
| + home := t.TempDir() |
| + touch(t, filepath.Join(home, "Library", "Caches", "ms-playwright", "chromium-1148", "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")) |
| + touch(t, cft(home, "1228", "mac-arm64")) |
| + want := cft(home, "1234", "mac-arm64") |
| + touch(t, want) |
| + if got := playwrightChrome(home); got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| + }) |
| + t.Run("macOS, older Chromium.app", func(t *testing.T) { |
| + home := t.TempDir() |
| + want := filepath.Join(home, "Library", "Caches", "ms-playwright", "chromium-1148", "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium") |
| + touch(t, want) |
| + if got := playwrightChrome(home); got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| + }) |
| + t.Run("Linux", func(t *testing.T) { |
| + home := t.TempDir() |
| + want := filepath.Join(home, ".cache", "ms-playwright", "chromium-1234", "chrome-linux64", "chrome") |
| + touch(t, want) |
| + if got := playwrightChrome(home); got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| + }) |
| +} |
diff --git a/internal/home/render.go b/internal/home/render.go| index f63ed7f..2ac21ab 100644 |
| --- a/internal/home/render.go |
| +++ b/internal/home/render.go |
| @@ -108,7 +108,7 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, nam |
| return |
| } |
| var buf bytes.Buffer |
| - if err := t.ExecuteTemplate(&buf, "layout", a.pageFor(r, data, doorPages[name], hopPages[name])); err != nil { |
| + if err := t.ExecuteTemplate(&buf, "layout", a.pageFor(r, status, data, doorPages[name], hopPages[name])); err != nil { |
| a.logger.Error("render", "page", name, "err", err) |
| a.renderFailed(w, r, name) |
| return |
diff --git a/internal/home/shared_chrome_test.go b/internal/home/shared_chrome_test.go| index 95feba1..8d9890a 100644 |
| --- a/internal/home/shared_chrome_test.go |
| +++ b/internal/home/shared_chrome_test.go |
| @@ -168,6 +168,172 @@ func TestAppearanceSetsTheTheme(t *testing.T) { |
| if !strings.Contains(body, `value="dark" aria-pressed="true"`) { |
| t.Error("Dark is not shown as the current appearance") |
| } |
| + |
| + // return_to is the attacker's half of the form: anything that would |
| + // leave the directory goes to "/" instead. Raw form bodies, as a |
| + // hostile page would post them: %09 and %5c decode to a tab and a |
| + // backslash, which browsers strip or fold into "/" before navigating. |
| + for _, hostile := range []string{"//evil", "/%09/evil", "/%5c/evil", "/%5C/evil", "https://evil", "https:%2F%2Fevil", "/%0d%0a/evil"} { |
| + rec := f.request("POST", "/appearance", directoryOrigin, "mode=dark&return_to="+hostile, person) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { |
| + t.Errorf("return_to=%s: %d to %q, want 303 to /", hostile, rec.Code, rec.Header().Get("Location")) |
| + } |
| + } |
| +} |
| + |
| +// Appearance comes back to the page it was chosen on only if that page |
| +// is there to come back to: a GET that drew a 200. A 404's own path, an |
| +// error page's, or the path of a POST that drew a page would 303 back to |
| +// a page that is not there (a POST-only path answers a GET with a 404). |
| +func TestAppearanceReturnsOnlyToARealPage(t *testing.T) { |
| + f := newMountedChooser(t) |
| + person := f.session(t, chooserPerson) |
| + directory := f.p.directory.Router() |
| + returnTo := regexp.MustCompile(`name="return_to" value="([^"]*)"`) |
| + for _, c := range []struct { |
| + name, target, want string |
| + }{ |
| + {"the directory", "/", "/"}, |
| + {"open source", "/help/open-source", "/help/open-source"}, |
| + {"a 404", "/no/such/page", "/"}, |
| + {"a GET to a POST-only path", "/appearance", "/"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + body := chromeGet(directory, directoryOrigin+c.target, acceptHTML, person).Body.String() |
| + m := returnTo.FindStringSubmatch(body) |
| + if m == nil { |
| + t.Fatalf("no Appearance return_to on %s", c.target) |
| + } |
| + if m[1] != c.want { |
| + t.Errorf("return_to %q, want %q", m[1], c.want) |
| + } |
| + }) |
| + } |
| + // A page drawn in answer to a POST, or with any status but 200. |
| + for _, c := range []struct { |
| + method string |
| + status int |
| + }{ |
| + {http.MethodPost, http.StatusOK}, |
| + {http.MethodGet, http.StatusServiceUnavailable}, |
| + {http.MethodGet, http.StatusInternalServerError}, |
| + } { |
| + r := httptest.NewRequest(c.method, directoryOrigin+"/help/open-source", nil) |
| + r.Header.Set("Accept", acceptHTML) |
| + r.AddCookie(person) |
| + var got string |
| + f.p.directory.sessions.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + got = f.p.directory.account(r, c.status).Appearance.Return |
| + })).ServeHTTP(httptest.NewRecorder(), r) |
| + if got != "/" { |
| + t.Errorf("%s drawing a %d: return_to %q, want \"/\"", c.method, c.status, got) |
| + } |
| + } |
| +} |
| + |
| +// A 500 for a GET offers to try the page again beside the way home; it |
| +// does not replace the way home, which is the one link known to work. |
| +func TestServerErrorKeepsTheWayHome(t *testing.T) { |
| + f := newMountedChooser(t) |
| + for _, c := range []struct { |
| + name string |
| + app *App |
| + method string |
| + target string |
| + home string |
| + tryAgain bool |
| + }{ |
| + {"directory, GET", f.p.directory, http.MethodGet, directoryOrigin + "/help/open-source?x=1", "/", true}, |
| + {"directory, POST", f.p.directory, http.MethodPost, directoryOrigin + "/appearance", "/", false}, |
| + {"id.*, GET", f.p.credential.App, http.MethodGet, Origin + "/passkey/codes", directoryOrigin + "/", true}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + r := httptest.NewRequest(c.method, c.target, nil) |
| + r.Header.Set("Accept", acceptHTML) |
| + rec := httptest.NewRecorder() |
| + c.app.serverError(rec, r) |
| + body := rec.Body.String() |
| + if rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("status %d", rec.Code) |
| + } |
| + if !strings.Contains(body, `<a rst-btn="primary" href="`+c.home+`">Go to Oficina</a>`) { |
| + t.Errorf("no Go to Oficina link to %s: %s", c.home, errorCTA(body)) |
| + } |
| + again := `<a rst-btn href="` + strings.TrimPrefix(strings.TrimPrefix(c.target, directoryOrigin), Origin) + `">Try again</a>` |
| + if got := strings.Contains(body, ">Try again</a>"); got != c.tryAgain { |
| + t.Errorf("Try again shown %v, want %v: %s", got, c.tryAgain, errorCTA(body)) |
| + } |
| + if c.tryAgain && !strings.Contains(body, again) { |
| + t.Errorf("Try again does not retry the page (want %s): %s", again, errorCTA(body)) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// errorCTA is the error page's links, for a failure message. |
| +func errorCTA(body string) string { |
| + if i := strings.Index(body, "<p rst-error-cta>"); i >= 0 { |
| + end := strings.Index(body[i:], "</p>") |
| + if end > 0 { |
| + return body[i : i+end] |
| + } |
| + } |
| + return "(no rst-error-cta)" |
| +} |
| + |
| +// Every status the error page can be asked for has its own words; one |
| +// nobody wrote words for gets a general page, never a blank heading. |
| +func TestErrorPageHasCopyForEveryStatus(t *testing.T) { |
| + f := newMountedChooser(t) |
| + headings := map[string]int{} |
| + for _, status := range []int{ |
| + http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, |
| + http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestEntityTooLarge, |
| + http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable, |
| + } { |
| + heading := checkErrorCopy(t, f.p.directory, status) |
| + if other, ok := headings[heading]; ok { |
| + t.Errorf("%d and %d share the heading %q", status, other, heading) |
| + } |
| + headings[heading] = status |
| + } |
| + // Statuses with no words of their own: a general page. |
| + for _, status := range []int{http.StatusTeapot, http.StatusConflict, http.StatusBadGateway} { |
| + checkErrorCopy(t, f.p.directory, status) |
| + } |
| +} |
| + |
| +var ( |
| + errorHeading = regexp.MustCompile(`<h1 rst-error-title>([^<]*)</h1>`) |
| + errorBody = regexp.MustCompile(`<p rst-error-body>([^<]*)</p>`) |
| +) |
| + |
| +// checkErrorCopy draws status's error page and fails on any part of its |
| +// copy left blank. It returns the heading. |
| +func checkErrorCopy(t *testing.T, a *App, status int) string { |
| + t.Helper() |
| + r := httptest.NewRequest(http.MethodGet, directoryOrigin+"/x", nil) |
| + r.Header.Set("Accept", acceptHTML) |
| + rec := httptest.NewRecorder() |
| + a.errorPage(rec, r, status) |
| + body := rec.Body.String() |
| + if rec.Code != status { |
| + t.Errorf("%d: drew a %d", status, rec.Code) |
| + } |
| + title := titleOf(body) |
| + h := errorHeading.FindStringSubmatch(body) |
| + b := errorBody.FindStringSubmatch(body) |
| + if title == "" || strings.HasPrefix(title, " ·") || strings.HasPrefix(title, "·") { |
| + t.Errorf("%d: tab title %q", status, title) |
| + } |
| + if h == nil || strings.TrimSpace(h[1]) == "" { |
| + t.Errorf("%d: no heading", status) |
| + return "" |
| + } |
| + if b == nil || strings.TrimSpace(b[1]) == "" { |
| + t.Errorf("%d: no body copy", status) |
| + } |
| + return h[1] |
| } |
| // estilo's error page on both origins, for a browser; everything else |
diff --git a/internal/home/templates/assertion.html b/internal/home/templates/assertion.html| index 3c7887a..723462b 100644 |
| --- a/internal/home/templates/assertion.html |
| +++ b/internal/home/templates/assertion.html |
| @@ -1,9 +1,11 @@ |
| {{define "title"}}Continue to Oficina · Oficina{{end}} |
| {{define "head"}} |
| {{/* The page submits itself (static/assertion.js), so its card stays |
| - hidden for 1.5 s and shows only if the submit stalls; the bar and the |
| - sky's placeholder show at once (layout.html, hop). With scripts off nothing submits, so noscript shows it |
| - at once. Spec shared-chrome §9.3. */}} |
| + hidden for 1.5 s and shows only if the submit stalls. With scripts |
| + off nothing submits, so noscript shows it at once. That rule is spec |
| + shared-chrome §9.3. The bar and the sky's placeholder sit outside the |
| + card and show at once, so the hop is never a blank page: that is |
| + layout.html's hop handling (review r3 C2), not §9.3. */}} |
| <style> |
| [estilo-handoff] { animation: estilo-reveal 0s 1.5s both; } |
| @keyframes estilo-reveal { from { visibility: hidden } to { visibility: visible } } |
diff --git a/internal/home/zoom_browser_test.go b/internal/home/zoom_browser_test.go| index 9fb14ad..23b5f9d 100644 |
| --- a/internal/home/zoom_browser_test.go |
| +++ b/internal/home/zoom_browser_test.go |
| @@ -14,6 +14,8 @@ import ( |
| "context" |
| "fmt" |
| "net/http" |
| + "os" |
| + "os/exec" |
| "strings" |
| "testing" |
| "time" |
| @@ -29,6 +31,29 @@ import ( |
| // zooms for them. |
| const textControls = `input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=file]):not([type=submit]):not([type=button]):not([type=color]):not([type=range]), select, textarea` |
| +// useChrome points rastrillo's harness at a Chromium it would not find by |
| +// itself: when RASTRILLO_CHROME is unset and no Chromium is on PATH, the |
| +// newest one in a Playwright cache, the macOS layouts included |
| +// (playwrightChrome, chromefinder_test.go). Call it before harness.New. |
| +func useChrome(t *testing.T) { |
| + t.Helper() |
| + if os.Getenv("RASTRILLO_CHROME") != "" { |
| + return |
| + } |
| + for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable"} { |
| + if _, err := exec.LookPath(name); err == nil { |
| + return |
| + } |
| + } |
| + home, err := os.UserHomeDir() |
| + if err != nil { |
| + return |
| + } |
| + if p := playwrightChrome(home); p != "" { |
| + t.Setenv("RASTRILLO_CHROME", p) |
| + } |
| +} |
| + |
| // asHost serves h as if every request had arrived for host. The rig's |
| // browser talks to http://localhost:<port>, and Home answers only its two |
| // configured hosts (serveKnownHost); this is the browser-side half of the |
| @@ -83,6 +108,7 @@ func TestNoZoomOnTap(t *testing.T) { |
| {docsEntryPath, `button[name="team"]`}, |
| }}, |
| } |
| + useChrome(t) |
| for _, host := range hosts { |
| rig := harness.New(t, host.handler) |
| run := func(what string, actions ...chromedp.Action) { |