Files
cthulhu/internal/app/browser_dynamic_test.go

153 lines
4.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeBrowserDynamicNavigation(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
page := `<!doctype html><title>Dynamic navigation regression</title>
<a href="#end">Start of page</a>
<button aria-label="Counter" onfocus="this.setAttribute('aria-label', 'Counter updated')">Counter</button>
<p>After control</p>
<svg role="img" aria-label="Avatar" width="16" height="16"><circle cx="8" cy="8" r="8"/></svg>
<button aria-label="Add content" onfocus="const p = document.createElement('p'); p.textContent = 'New content'; document.body.appendChild(p); this.setAttribute('aria-label', 'Add content done')">Add content</button>`
// A large document must remain navigable without exhausting the live
// two-second first-capture budget before any browse position exists.
for i := 0; i < 1000; i++ {
page += fmt.Sprintf("<p>Unrelated paragraph %d</p>", i)
}
page += `<p id="end">End of page</p>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, page)
}))
defer server.Close()
adapter := atspi.New()
adapter.SetCaptureSoundGeometry(true)
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
command := func(args ...string) {
t.Helper()
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool: %v: %s", err, output)
}
}
command("key", "--clearmodifiers", "ctrl+l")
command("type", "--clearmodifiers", "--delay", "0", server.URL)
command("key", "Return")
var document atspi.ObjectID
var applicationName string
for !document.Valid() {
select {
case event := <-adapter.Events():
if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Dynamic navigation regression" {
document = event.Source.ID()
applicationName = event.Source.ApplicationName
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
worker, err := review.NewWorker(adapter, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.SetFlatCaptureTimeout(10 * time.Second); err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.SyncFocus(document)
move := func(command review.DocumentCaretCommand, want string) {
t.Helper()
if err := worker.MoveDocumentCaret(command); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
got := result.DocumentCaretText
if got == "" {
got = result.DocumentPosition.Snapshot.Name
}
if result.Err != nil || result.CaretErr != nil || !result.Moved || !strings.Contains(got, want) {
t.Fatalf("next line = %q, want %q: %+v", got, want, result)
}
if result.DocumentPosition.Snapshot.ApplicationName != applicationName {
t.Fatalf("document target application = %q, want %q", result.DocumentPosition.Snapshot.ApplicationName, applicationName)
}
t.Logf("line=%q elapsed=%s capture=%s reason=%v", got, result.Elapsed, result.CaptureDuration, result.CaptureReason)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
next := func(want string) {
t.Helper()
move(review.CaretNextLine, want)
}
next("Start of page")
next("Counter")
waitName := func(name string) {
t.Helper()
for {
select {
case event := <-adapter.Events():
if event.Type == atspi.AccessibleNameEvent && event.Source.Name == name {
return
}
case <-ctx.Done():
t.Fatalf("name change to %q was not observed", name)
}
}
}
waitName("Counter updated")
next("After control")
next("Avatar")
next("Add content")
waitName("Add content done")
next("Unrelated paragraph 0")
next("Unrelated paragraph 1")
move(review.CaretDocumentEnd, "New content")
for _, unit := range []review.FlatUnit{review.WindowEnd, review.Line} {
started := time.Now()
step := 0
if unit == review.Line {
step = -1
}
if err := worker.ReadFlat(unit, step); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
t.Logf("flat review unit %d elapsed=%s capture=%s reason=%v", unit, time.Since(started), result.CaptureDuration, result.CaptureReason)
if result.Err != nil || strings.TrimSpace(result.FlatPosition.Text) == "" || unit == review.Line && !result.Moved {
t.Fatalf("large-page flat review unit %d = %+v", unit, result)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}