Web fixes.
This commit is contained in:
1 parent
914d174925
commit
453206b85b
11 files changed
+643
-25
No files matched your search
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
)
|
||||
|
||||
func TestNativeBrowserActionOwnership(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||
t.Skip("requires private browser action fixture")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
var focus atspi.ObjectID
|
||||
for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ {
|
||||
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil {
|
||||
t.Fatalf("fixture focus: %v %s", err, out)
|
||||
}
|
||||
timer := time.NewTimer(150 * time.Millisecond)
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if isFocusGainEvent(event) && event.Source.Name == "Action fixture start" {
|
||||
focus = event.Source.ID()
|
||||
break drain
|
||||
}
|
||||
case <-timer.C:
|
||||
break drain
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
timer.Stop()
|
||||
}
|
||||
if !focus.Valid() {
|
||||
t.Fatal("fixture heading focus missing")
|
||||
}
|
||||
tree, err := adapter.CaptureDocument(ctx, focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, _ := tree.Root()
|
||||
document, err := tree.Document(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
anchors := map[string]atspi.ObjectReference{}
|
||||
labels := map[string]atspi.ObjectReference{}
|
||||
for _, node := range document.Nodes {
|
||||
snapshot := node.Snapshot
|
||||
if snapshot.ElementID == "Basic" || snapshot.ElementID == "Advanced" {
|
||||
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("%s anchor actions: %+v", snapshot.ElementID, actions.Actions)
|
||||
if snapshot.Role != "section" || snapshot.HTMLTag != "a" || snapshot.Focusable || !snapshot.SupportsClick {
|
||||
t.Fatalf("custom anchor lost activation semantics: %+v", snapshot)
|
||||
}
|
||||
anchors[strings.ToUpper(snapshot.ElementID)] = node.Reference
|
||||
}
|
||||
if snapshot.Role == "static" && (snapshot.Name == "BASIC" || snapshot.Name == "ADVANCED") {
|
||||
labels[snapshot.Name] = node.Reference
|
||||
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, action := range actions.Actions {
|
||||
found = found || action.Name == "clickAncestor"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("label does not reproduce Chromium ancestor activation: %+v", actions)
|
||||
}
|
||||
}
|
||||
if snapshot.Role == "static" || snapshot.ElementID == "inactive" || snapshot.Role == "paragraph" || atspi.IsWebDocumentRole(snapshot.Role) {
|
||||
if snapshot.SupportsClick {
|
||||
t.Fatalf("passive text/container became a clickable: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(anchors) != 2 || len(labels) != 2 {
|
||||
t.Fatalf("missing mode controls: %v, %v", anchors, labels)
|
||||
}
|
||||
cursor, _ := review.NewDocumentCursor(tree)
|
||||
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for step := 0; step < 30; step++ {
|
||||
command := review.CaretNextLine
|
||||
if step == 0 {
|
||||
command = review.CaretDocumentStart
|
||||
}
|
||||
move, err := cursor.MoveCaret(ctx, adapter, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !move.Moved {
|
||||
break
|
||||
}
|
||||
name := strings.TrimSpace(move.Text)
|
||||
if _, ok := labels[name]; ok {
|
||||
counts[name]++
|
||||
target, err := cursor.ActivationTarget()
|
||||
if err != nil || target != anchors[name] {
|
||||
t.Fatalf("label %s activates %v: %v", name, target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if counts["BASIC"] != 1 || counts["ADVANCED"] != 1 {
|
||||
t.Fatalf("mode reading counts: %v", counts)
|
||||
}
|
||||
// Structural clickable navigation discovers the owners, not their labels.
|
||||
cursor, _ = review.NewDocumentCursor(tree)
|
||||
cursor.SyncFocus(ctx, focus)
|
||||
found := map[atspi.ObjectID]bool{}
|
||||
for step := 0; step < 12; step++ {
|
||||
move, err := cursor.Move(ctx, review.DocumentClickable, 0, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !move.Moved || found[move.Position.Object.ID()] {
|
||||
break
|
||||
}
|
||||
found[move.Position.Object.ID()] = true
|
||||
}
|
||||
for name, anchor := range anchors {
|
||||
if !found[anchor.ID()] || found[labels[name].ID()] {
|
||||
t.Fatalf("clickable discovery for %s: %v", name, found)
|
||||
}
|
||||
}
|
||||
for _, mode := range []string{"Basic", "Advanced"} {
|
||||
// Reacquire references after each iframe replacement. Enter's target
|
||||
// still comes from the text leaf, with native focus on the start heading.
|
||||
tree, err = adapter.CaptureDocument(ctx, focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, _ = tree.Root()
|
||||
document, err = tree.Document(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var label atspi.ObjectID
|
||||
for _, node := range document.Nodes {
|
||||
if node.Snapshot.Role == "static" && node.Snapshot.Name == strings.ToUpper(mode) {
|
||||
label = node.Reference.ID()
|
||||
}
|
||||
}
|
||||
cursor, _ = review.NewDocumentCursor(tree)
|
||||
if _, err := cursor.SyncFocus(ctx, label); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := cursor.ActivationTarget()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := adapter.ActivateDocumentObject(ctx, target, focus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activated := false
|
||||
for !activated {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
activated = event.Type == atspi.AccessibleNameEvent && event.Source.Name == mode+" activated"
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("%s action did not reach its delegated handler: %v", mode, ctx.Err())
|
||||
}
|
||||
}
|
||||
// The content iframe changes asynchronously. Capture until its new
|
||||
// document is available and ensure the old document is gone.
|
||||
for {
|
||||
tree, err = adapter.CaptureDocument(ctx, focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, _ = tree.Root()
|
||||
document, err = tree.Document(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := false
|
||||
for _, node := range document.Nodes {
|
||||
ready = ready || node.Snapshot.Name == mode+" mode content"
|
||||
}
|
||||
if ready {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
t.Fatal("replacement content missing", ctx.Err())
|
||||
}
|
||||
}
|
||||
readNewContent := false
|
||||
for step := 0; step < 8; step++ {
|
||||
move, err := cursor.MoveCaret(ctx, adapter, review.CaretNextLine)
|
||||
if err != nil {
|
||||
t.Fatalf("reading after %s activation: %v", mode, err)
|
||||
}
|
||||
if strings.TrimSpace(move.Text) == mode+" mode content" {
|
||||
readNewContent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !readNewContent {
|
||||
t.Fatalf("browse caret could not reach %s replacement content", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user