Files
cthulhu/internal/app/browser_dynamic_test.go

209 lines
7.5 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"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"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'); let n = 0; const timer = setInterval(() => { this.setAttribute('aria-label', 'Counter ' + ++n); if (n === 6) clearInterval(timer); }, 150)">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)
}
attempts := result.DocumentDiagnostics.Attempts
if len(attempts) == 0 || attempts[len(attempts)-1].After.Object != result.DocumentPosition.Object.ID() {
t.Fatalf("navigation diagnostics do not match the browser position: %+v", result.DocumentDiagnostics)
}
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) atspi.Event {
t.Helper()
for {
select {
case event := <-adapter.Events():
if event.Type == atspi.AccessibleNameEvent && event.Source.Name == name {
return event
}
case <-ctx.Done():
t.Fatalf("name change to %q was not observed", name)
}
}
}
counter := waitName("Counter updated")
// Exercise actual browser name events at the reported update cadence.
// The separate event-loop regression covers routing and immediate Braille.
state := &eventLoopState{}
state.current, state.focusedID, state.owner.window = counter.Source, counter.Source.ID(), counter.Window
browser := newBrowserModeController(nil, &modeKeys{}, nil)
browser.ready, browser.focus, browser.document = true, counter.Source.ID(), document
namePresentations := 0
handler := &accessibilityEventHandler{state: state, browser: browser, logger: slog.New(slog.DiscardHandler), speak: func(event atspi.Event, _ ...input.Intent) {
namePresentations++
if event.Source.Name != "Counter 6" {
t.Fatalf("changing native counter spoke before settling: %q", event.Source.Name)
}
}}
defer state.pending.clear()
updates := 0
for namePresentations == 0 {
select {
case event := <-adapter.Events():
if event.Type == atspi.AccessibleNameEvent && event.Source.ID() == counter.Source.ID() {
updates++
state.current = event.Source
if !handler.defer_browser_button_name(event, input.Activity{}) {
t.Fatal("native web button name was not coalesced")
}
}
case <-state.pending.browserButtonName.ready:
handler.flush_browser_button_name()
case <-ctx.Done():
t.Fatal("native counter did not finish: ", ctx.Err())
}
}
if updates < 2 {
t.Fatalf("observed only %d native counter updates", updates)
}
t.Logf("coalesced %d native counter updates into one settled announcement", updates)
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")
// Browsing to the end scrolls paragraphs which were previously not
// showing into view. Flat review must capture those paragraphs even if
// the provider's state cache still describes the initial viewport.
for index, tc := range []struct {
unit review.FlatUnit
step int
text string
moved bool
}{
{review.WindowEnd, 0, "New content", true},
{review.Line, -1, "End of page", true},
{review.Line, -1, "Unrelated paragraph 999", true},
{review.Line, 1, "End of page", true},
{review.Word, 1, "of", true},
{review.Character, 1, "f", true},
{review.WindowEnd, 0, "New content", true},
{review.Character, 0, "t", false},
{review.Line, 1, "New content", false},
{review.Line, -1, "End of page", true},
} {
started := time.Now()
if err := worker.ReadFlat(tc.unit, tc.step); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
t.Logf("flat review unit %d step=%d text=%q moved=%v elapsed=%s capture=%s reason=%v", tc.unit, tc.step, result.FlatPosition.Text, result.Moved, time.Since(started), result.CaptureDuration, result.CaptureReason)
if result.Err != nil || strings.TrimSpace(result.FlatPosition.Text) != tc.text || index > 0 && result.Moved != tc.moved {
t.Fatalf("large-page flat review unit %d step=%d want=%q moved=%v: %+v", tc.unit, tc.step, tc.text, tc.moved, result)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}