A width at or below Nextcloud's 1024px breakpoint keeps the tree off the page so the content stays usable, and the navigation button brings it back. Co-authored-by: Cursor <cursoragent@cursor.com>
271 lines
8.3 KiB
Go
271 lines
8.3 KiB
Go
package gonexapp_test
|
|
|
|
import (
|
|
"html"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/chromedp/chromedp"
|
|
"github.com/chromedp/chromedp/kb"
|
|
|
|
"gitea.neitzel.de/konrad/go-nc-exapp"
|
|
)
|
|
|
|
func sampleNavigation() gonexapp.AppNavigation {
|
|
items := []gonexapp.Item{
|
|
{
|
|
ID: "domains", Label: "Domains",
|
|
Children: []gonexapp.Item{{ID: "example.com", Label: "example.com"}},
|
|
},
|
|
{ID: "keys", Label: "Keys"},
|
|
}
|
|
return gonexapp.AppNavigation{
|
|
Items: func(*http.Request) []gonexapp.Item { return items },
|
|
Page: func(_ *http.Request, id string) (string, bool) {
|
|
switch id {
|
|
case "domains":
|
|
return "<p>domains-page</p>", true
|
|
case "keys":
|
|
return "<p>keys-page</p>", true
|
|
case "example.com":
|
|
return "<p>zone-page</p>", true
|
|
default:
|
|
return "", false
|
|
}
|
|
},
|
|
Header: func(*http.Request) string { return `<p id="visit">folder-line</p>` },
|
|
DefaultID: "domains",
|
|
MissingMessage: "that item is not in this Visit",
|
|
}
|
|
}
|
|
|
|
func navBody(t *testing.T, rawURL string) string {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
sampleNavigation().Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, rawURL, nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s: got %d", rawURL, rec.Code)
|
|
}
|
|
return rec.Body.String()
|
|
}
|
|
|
|
func navAddress(t *testing.T, body string) url.Values {
|
|
t.Helper()
|
|
const key = `data-address="`
|
|
_, after, ok := strings.Cut(body, key)
|
|
if !ok {
|
|
t.Fatalf("no data-address in %s", body)
|
|
}
|
|
rest := after
|
|
end := strings.Index(rest, `"`)
|
|
if end < 0 {
|
|
t.Fatalf("unclosed data-address")
|
|
}
|
|
raw, err := url.QueryUnescape(html.UnescapeString(rest[:end]))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q, err := url.ParseQuery(strings.TrimPrefix(raw, "?"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return q
|
|
}
|
|
|
|
func TestAppNavigationShowsSelectedPage(t *testing.T) {
|
|
body := navBody(t, "/?item=keys")
|
|
if !strings.Contains(body, "<p>keys-page</p>") {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
if !strings.Contains(body, `data-selected="keys"`) {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
if navAddress(t, body).Get("item") != "keys" {
|
|
t.Fatalf("address: %s", navAddress(t, body).Encode())
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationDefaultsWhenItemMissingFromAddress(t *testing.T) {
|
|
body := navBody(t, "/")
|
|
if !strings.Contains(body, "<p>domains-page</p>") {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
if navAddress(t, body).Get("item") != "domains" {
|
|
t.Fatalf("address item = %q", navAddress(t, body).Get("item"))
|
|
}
|
|
if strings.Contains(body, "that item is not in this Visit") {
|
|
t.Fatalf("default open should not show the missing-item message: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationMissingItemSelectsDefault(t *testing.T) {
|
|
body := navBody(t, "/?folder=share&item=gone")
|
|
if !strings.Contains(body, "<p>domains-page</p>") {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
if !strings.Contains(body, "that item is not in this Visit") {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
q := navAddress(t, body)
|
|
if q.Get("item") != "domains" || q.Get("folder") != "share" {
|
|
t.Fatalf("address = %s", q.Encode())
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationPageRefusalSelectsDefault(t *testing.T) {
|
|
nav := sampleNavigation()
|
|
inner := nav.Page
|
|
nav.Page = func(r *http.Request, id string) (string, bool) {
|
|
if id == "keys" {
|
|
return "", false
|
|
}
|
|
return inner(r, id)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?folder=share&item=keys", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "<p>domains-page</p>") || !strings.Contains(body, "that item is not in this Visit") {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
q := navAddress(t, body)
|
|
if q.Get("item") != "domains" || q.Get("folder") != "share" {
|
|
t.Fatalf("address = %s", q.Encode())
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationChildExpandsParents(t *testing.T) {
|
|
body := navBody(t, "/?item=example.com")
|
|
if !strings.Contains(body, "<p>zone-page</p>") {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
if !strings.Contains(body, `data-id="domains" data-expanded="true"`) {
|
|
t.Fatalf("got %s", body)
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationLoadsNextcloudTheme(t *testing.T) {
|
|
body := navBody(t, "/")
|
|
for _, need := range []string{
|
|
"/apps/theming/css/default.css",
|
|
"/index.php/apps/theming/theme/default.css",
|
|
"/index.php/apps/theming/theme/dark.css",
|
|
} {
|
|
if !strings.Contains(body, need) {
|
|
t.Fatalf("missing %s in %s", need, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationThemeCanBeDisabled(t *testing.T) {
|
|
nav := sampleNavigation()
|
|
nav.DisableTheme = true
|
|
rec := httptest.NewRecorder()
|
|
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if strings.Contains(rec.Body.String(), "/apps/theming/css/default.css") {
|
|
t.Fatalf("theme stylesheet still present: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationFoldControlFollowsLabel(t *testing.T) {
|
|
body := navBody(t, "/?item=domains")
|
|
name := strings.Index(body, ">Domains<")
|
|
fold := strings.Index(body, `aria-label="Fold Domains"`)
|
|
if name < 0 || fold < 0 || fold < name {
|
|
t.Fatalf("name %d fold %d", name, fold)
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationRendersIconBeforeLabel(t *testing.T) {
|
|
nav := sampleNavigation()
|
|
nav.Items = func(*http.Request) []gonexapp.Item {
|
|
return []gonexapp.Item{{ID: "domains", Label: "Domains", Icon: gonexapp.NextcloudIcons.Folder}}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?item=domains", nil))
|
|
body := rec.Body.String()
|
|
icon := strings.Index(body, `src="/core/img/filetypes/folder.svg"`)
|
|
name := strings.Index(body, ">Domains<")
|
|
if icon < 0 || name < 0 || icon > name {
|
|
t.Fatalf("icon %d name %d", icon, name)
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationHeaderPrecedesTree(t *testing.T) {
|
|
body := navBody(t, "/?item=domains")
|
|
header := strings.Index(body, "folder-line")
|
|
tree := strings.Index(body, ">Domains<")
|
|
if header < 0 || tree < 0 || header > tree {
|
|
t.Fatalf("header %d tree %d in %s", header, tree, body)
|
|
}
|
|
}
|
|
|
|
func TestAppNavigationOffersNarrowScreenToggle(t *testing.T) {
|
|
body := navBody(t, "/")
|
|
for _, need := range []string{
|
|
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
`id="nav-toggle"`,
|
|
`aria-controls="app-navigation"`,
|
|
`aria-expanded="false"`,
|
|
`aria-label="Open navigation"`,
|
|
`id="nav-backdrop"`,
|
|
`id="app-navigation"`,
|
|
`max-width: 1024px`,
|
|
} {
|
|
if !strings.Contains(body, need) {
|
|
t.Errorf("missing %s", need)
|
|
}
|
|
}
|
|
}
|
|
|
|
// navLayoutJS reports how App navigation is laid out: "wide", "closed", or "open".
|
|
const navLayoutJS = `(() => {
|
|
const nav = document.querySelector("nav.app-navigation");
|
|
const btn = document.getElementById("nav-toggle");
|
|
const narrow = window.matchMedia("(max-width: 1024px)").matches;
|
|
const box = nav.getBoundingClientRect();
|
|
const navShown = getComputedStyle(nav).display !== "none" && box.width > 0 && box.left < window.innerWidth && box.right > 0;
|
|
const btnShown = getComputedStyle(btn).display !== "none";
|
|
const expanded = btn.getAttribute("aria-expanded") === "true";
|
|
if (!narrow && navShown && !btnShown && !expanded) return "wide";
|
|
if (narrow && !navShown && btnShown && !expanded) return "closed";
|
|
if (narrow && navShown && btnShown && expanded) return "open";
|
|
return "narrow=" + narrow + " nav=" + navShown + " btn=" + btnShown + " expanded=" + expanded + " width=" + window.innerWidth;
|
|
})()`
|
|
|
|
func TestAppNavigationWideScreenKeepsTheTreeVisible(t *testing.T) {
|
|
drive(t, "/nav",
|
|
chromedp.EmulateViewport(1280, 800),
|
|
waitJS(navLayoutJS, "wide", false),
|
|
)
|
|
}
|
|
|
|
func TestAppNavigationNarrowScreenOpensTheTreeOnDemand(t *testing.T) {
|
|
drive(t, "/nav",
|
|
chromedp.EmulateViewport(390, 800),
|
|
waitJS(navLayoutJS, "closed", false),
|
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
|
waitJS(navLayoutJS, "open", false),
|
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
|
waitJS(navLayoutJS, "closed", false),
|
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
|
waitJS(navLayoutJS, "open", false),
|
|
chromedp.MouseClickXY(370, 400),
|
|
waitJS(navLayoutJS, "closed", false),
|
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
|
waitJS(navLayoutJS, "open", false),
|
|
chromedp.KeyEvent(kb.Escape),
|
|
waitJS(navLayoutJS, "closed", false),
|
|
chromedp.Click("#nav-toggle", chromedp.ByQuery),
|
|
waitJS(navLayoutJS, "open", false),
|
|
chromedp.Click(`a.app-navigation-entry-link[href*="item=keys"]`, chromedp.ByQuery),
|
|
waitJS(`document.querySelector("main").textContent`, "keys-page", true),
|
|
waitJS(navLayoutJS, "closed", false),
|
|
)
|
|
}
|