209 lines
7.5 KiB
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())
|
|
}
|
|
}
|
|
}
|