Files
konradandCursor fad9806585 Hide App navigation on a narrow screen until the user opens it.
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>
2026-09-29 18:56:44 +02:00

520 lines
16 KiB
Go

package gonexapp_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/kb"
"gitea.neitzel.de/konrad/go-nc-exapp"
)
func TestMain(m *testing.M) {
srv := httptest.NewServer(dialogFixture())
defer srv.Close()
fixtureURL = srv.URL
path := browserExecutable()
if path == "" {
fmt.Fprintln(os.Stderr, "dialog tests need Chrome, Edge, or Chromium (set EXAPP_BROWSER)")
os.Exit(1)
}
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ExecPath(path),
chromedp.WindowSize(1200, 800),
)
var cancel context.CancelFunc
allocCtx, cancel = chromedp.NewExecAllocator(context.Background(), opts...)
code := m.Run()
cancel()
os.Exit(code)
}
var (
fixtureURL string
allocCtx context.Context
)
func dialogFixture() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /bare", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, "<!DOCTYPE html><html><head><meta charset=utf-8><title>Dialog</title></head><body>",
gonexapp.DialogHTML(), fixtureControls(), "</body></html>")
})
nav := gonexapp.AppNavigation{
DefaultID: "home",
Items: func(*http.Request) []gonexapp.Item {
return []gonexapp.Item{{ID: "home", Label: "Home"}}
},
Page: func(*http.Request, string) (string, bool) {
return fixtureControls(), true
},
}
mux.Handle("GET /shell", nav.Handler())
mux.Handle("GET /nav", sampleNavigation().Handler())
return mux
}
func fixtureControls() string {
return `<button type="button" id="open-info">Open info</button>
<button type="button" id="open-warning">Open warning</button>
<button type="button" id="open-error">Open error</button>
<button type="button" id="open-plain">Open plain</button>
<button type="button" id="open-confirm">Open confirm</button>
<button type="button" id="open-delete">Open delete</button>
<button type="button" id="open-html-button">Open html button</button>
<button type="button" id="open-prompt">Open prompt</button>
<button type="button" id="open-prefill">Open prefill</button>
<button type="button" id="open-blank">Open blank</button>
<button type="button" id="open-inner">Open inner</button>
<button type="button" id="open-three">Open three</button>
<pre id="log"></pre>
<script>
function log(line) { document.getElementById("log").textContent += line + "\n"; }
document.getElementById("open-info").onclick = function () {
exappDialog.message({ severity: "info", heading: "Notice", text: "Hello <b>x</b>", agree: "Nope" }).then(function () { log("dismissed"); });
};
document.getElementById("open-warning").onclick = function () {
exappDialog.message({ severity: "warning", text: "Careful" }).then(function () { log("dismissed"); });
};
document.getElementById("open-error").onclick = function () {
exappDialog.message({ severity: "error", heading: "<b>Nope</b>", text: "Failed" }).then(function () { log("dismissed"); });
};
document.getElementById("open-plain").onclick = function () {
exappDialog.message({ severity: "info", text: "Only the message" }).then(function () { log("dismissed"); });
};
document.getElementById("open-confirm").onclick = function () {
exappDialog.confirm({ text: "Proceed?" }).then(function (yes) { log(yes ? "yes" : "no"); });
};
document.getElementById("open-delete").onclick = function () {
exappDialog.confirm({ heading: "Remove domain", text: "Gone", agree: "Delete", destructive: true }).then(function (yes) { log(yes ? "yes" : "no"); });
};
document.getElementById("open-html-button").onclick = function () {
exappDialog.confirm({ text: "Go?", agree: "<i>Go</i>" }).then(function (yes) { log(yes ? "yes" : "no"); });
};
document.getElementById("open-prompt").onclick = function () {
exappDialog.prompt({ heading: "New folder", text: "Folder name" }).then(function (value) {
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
});
};
document.getElementById("open-prefill").onclick = function () {
exappDialog.prompt({ text: "Name", value: " hi " }).then(function (value) {
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
});
};
document.getElementById("open-blank").onclick = function () {
exappDialog.prompt({ text: "Name", value: " " }).then(function (value) {
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
});
};
document.getElementById("open-inner").onclick = function () {
exappDialog.prompt({ text: "Name", value: "a b" }).then(function (value) {
log(value === null ? "cancelled" : "value:" + JSON.stringify(value));
});
};
document.getElementById("open-three").onclick = function () {
exappDialog.message({ severity: "info", text: "First" }).then(function () { log("1"); });
exappDialog.message({ severity: "warning", text: "Second" }).then(function () { log("2"); });
exappDialog.message({ severity: "error", text: "Third" }).then(function () { log("3"); });
};
</script>`
}
func browserExecutable() string {
if p := os.Getenv("EXAPP_BROWSER"); p != "" {
return p
}
candidates := []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
func drive(t *testing.T, path string, actions ...chromedp.Action) {
t.Helper()
ctx, cancel := chromedp.NewContext(allocCtx)
t.Cleanup(cancel)
ctx, cancel = context.WithTimeout(ctx, 25*time.Second)
t.Cleanup(cancel)
steps := append([]chromedp.Action{chromedp.Navigate(fixtureURL + path)}, actions...)
if err := chromedp.Run(ctx, steps...); err != nil {
t.Fatal(err)
}
}
func clickButton(label string) chromedp.Action {
expr := fmt.Sprintf(`(() => {
const btn = [...document.querySelectorAll("#exapp-dialog button")].find((b) => b.textContent.trim() === %q && !b.hidden);
if (!btn) return "missing";
btn.click();
return "clicked";
})()`, label)
return chromedp.ActionFunc(func(ctx context.Context) error {
var result string
if err := chromedp.Evaluate(expr, &result).Do(ctx); err != nil {
return err
}
if result != "clicked" {
return fmt.Errorf("button %q: %s", label, result)
}
return nil
})
}
func waitJS(expr, want string, contains bool) chromedp.Action {
return chromedp.ActionFunc(func(ctx context.Context) error {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(5 * time.Second)
}
var last string
for {
var got string
err := chromedp.Evaluate(expr, &got).Do(ctx)
if err == nil {
last = got
if contains && strings.Contains(got, want) {
return nil
}
if !contains && strings.TrimSpace(got) == want {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("%s = %q, want %q", expr, last, want)
}
if err := chromedp.Sleep(40 * time.Millisecond).Do(ctx); err != nil {
return err
}
}
})
}
func waitDialogText(id, want string) chromedp.Action {
return waitJS(fmt.Sprintf(`document.getElementById("%s").textContent`, id), want, false)
}
func waitLog(want string) chromedp.Action {
return waitJS(`document.getElementById("log").textContent`, want, true)
}
func openShows(t *testing.T, path, button, severity, heading, message string) {
t.Helper()
drive(t, path,
chromedp.WaitVisible("#"+button, chromedp.ByQuery),
chromedp.Click("#"+button, chromedp.ByQuery),
waitDialogText("exapp-dialog-severity", severity),
waitDialogText("exapp-dialog-message", message),
chromedp.ActionFunc(func(ctx context.Context) error {
var head string
var hidden bool
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").textContent`, &head).Do(ctx); err != nil {
return err
}
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").hidden`, &hidden).Do(ctx); err != nil {
return err
}
if heading == "" {
if !hidden {
return fmt.Errorf("heading visible %q", head)
}
return nil
}
if hidden || strings.TrimSpace(head) != heading {
return fmt.Errorf("heading %q hidden %v, want %q", head, hidden, heading)
}
var bold int
if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog b, #exapp-dialog i").length`, &bold).Do(ctx); err != nil {
return err
}
if bold != 0 {
return fmt.Errorf("dialog interpreted HTML, %d elements", bold)
}
var label string
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
return err
}
if strings.TrimSpace(label) != "OK" {
return fmt.Errorf("message button %q, want OK", label)
}
return nil
}),
)
}
func TestMessageOnBarePage(t *testing.T) {
openShows(t, "/bare", "open-info", "info", "Notice", "Hello <b>x</b>")
}
func TestMessageOnAppNavigation(t *testing.T) {
openShows(t, "/shell", "open-info", "info", "Notice", "Hello <b>x</b>")
}
func TestMessageSeveritiesAndDismiss(t *testing.T) {
cases := []struct {
button string
severity string
how string
}{
{"open-warning", "warning", "ok"},
{"open-error", "error", "enter"},
{"open-info", "info", "escape"},
{"open-plain", "info", "backdrop"},
}
for _, tc := range cases {
t.Run(tc.how, func(t *testing.T) {
var dismiss chromedp.Action
switch tc.how {
case "ok":
dismiss = clickButton("OK")
case "enter":
dismiss = chromedp.KeyEvent(kb.Enter)
case "escape":
dismiss = chromedp.KeyEvent(kb.Escape)
case "backdrop":
dismiss = chromedp.MouseClickXY(8, 8)
}
drive(t, "/bare",
chromedp.Click("#"+tc.button, chromedp.ByQuery),
waitDialogText("exapp-dialog-severity", tc.severity),
dismiss,
waitLog("dismissed"),
)
})
}
}
func TestMessageWithoutHeading(t *testing.T) {
openShows(t, "/bare", "open-plain", "info", "", "Only the message")
}
func TestConfirmYesNoAndDestructive(t *testing.T) {
t.Run("cancel", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-confirm", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Proceed?"),
clickButton("Cancel"),
waitLog("no"),
)
})
t.Run("escape", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-confirm", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Proceed?"),
chromedp.KeyEvent(kb.Escape),
waitLog("no"),
)
})
t.Run("backdrop", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-confirm", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Proceed?"),
chromedp.MouseClickXY(8, 8),
waitLog("no"),
)
})
t.Run("enter", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-confirm", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Proceed?"),
chromedp.KeyEvent(kb.Enter),
waitLog("yes"),
)
})
t.Run("delete", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-delete", chromedp.ByQuery),
waitDialogText("exapp-dialog-heading", "Remove domain"),
chromedp.ActionFunc(func(ctx context.Context) error {
var plain, destructive string
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-cancel")).backgroundColor`, &plain).Do(ctx); err != nil {
return err
}
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-agree")).backgroundColor`, &destructive).Do(ctx); err != nil {
return err
}
if plain == destructive {
return fmt.Errorf("destructive button color %q matches Cancel", destructive)
}
var text string
if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-agree")).color`, &text).Do(ctx); err != nil {
return err
}
if text == "rgb(255, 255, 255)" || text == destructive {
return fmt.Errorf("destructive label color %q on background %q", text, destructive)
}
var label string
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
return err
}
if strings.TrimSpace(label) != "Delete" {
return fmt.Errorf("agree label %q", label)
}
return nil
}),
clickButton("Delete"),
waitLog("yes"),
)
})
t.Run("button word is plain text", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-html-button", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Go?"),
chromedp.ActionFunc(func(ctx context.Context) error {
var n int
var label string
if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog i").length`, &n).Do(ctx); err != nil {
return err
}
if n != 0 {
return fmt.Errorf("button word was interpreted as HTML")
}
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
return err
}
if strings.TrimSpace(label) != "<i>Go</i>" {
return fmt.Errorf("agree label %q", label)
}
return nil
}),
)
})
}
func TestPromptValueAndCancel(t *testing.T) {
t.Run("empty stays inactive", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-prompt", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Folder name"),
waitDialogText("exapp-dialog-heading", "New folder"),
chromedp.ActionFunc(func(ctx context.Context) error {
var disabled bool
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
return err
}
if !disabled {
return fmt.Errorf("agree button active on an empty Prompt")
}
return chromedp.KeyEvent(kb.Enter).Do(ctx)
}),
chromedp.Sleep(200*time.Millisecond),
chromedp.ActionFunc(func(ctx context.Context) error {
var open bool
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog").open`, &open).Do(ctx); err != nil {
return err
}
if !open {
return fmt.Errorf("Enter closed an empty Prompt")
}
return nil
}),
clickButton("Cancel"),
waitLog("cancelled"),
)
})
t.Run("spaces only stay inactive", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-blank", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Name"),
chromedp.ActionFunc(func(ctx context.Context) error {
var disabled bool
var raw string
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
return err
}
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
return err
}
if raw != " " {
return fmt.Errorf("starting value %q", raw)
}
if !disabled {
return fmt.Errorf("agree button active for spaces")
}
return nil
}),
)
})
t.Run("prefill trims on agree", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-prefill", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Name"),
chromedp.ActionFunc(func(ctx context.Context) error {
var raw string
if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
return err
}
if raw != " hi " {
return fmt.Errorf("starting value %q", raw)
}
return nil
}),
clickButton("OK"),
waitLog(`value:"hi"`),
)
})
t.Run("inner spaces stay", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-inner", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Name"),
clickButton("OK"),
waitLog(`value:"a b"`),
)
})
t.Run("escape cancels", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-prompt", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Folder name"),
chromedp.KeyEvent(kb.Escape),
waitLog("cancelled"),
)
})
t.Run("typed value", func(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-prompt", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "Folder name"),
chromedp.SendKeys("#exapp-dialog-value", " mail ", chromedp.ByQuery),
chromedp.KeyEvent(kb.Enter),
waitLog(`value:"mail"`),
)
})
}
func TestOneDialogAtATime(t *testing.T) {
drive(t, "/bare",
chromedp.Click("#open-three", chromedp.ByQuery),
waitDialogText("exapp-dialog-message", "First"),
chromedp.ActionFunc(func(ctx context.Context) error {
var n int
if err := chromedp.Evaluate(`document.querySelectorAll("dialog[open]").length`, &n).Do(ctx); err != nil {
return err
}
if n != 1 {
return fmt.Errorf("open dialogs %d, want 1", n)
}
return nil
}),
clickButton("OK"),
waitDialogText("exapp-dialog-message", "Second"),
clickButton("OK"),
waitDialogText("exapp-dialog-message", "Third"),
clickButton("OK"),
waitLog("1\n2\n3"),
)
}