444 lines
13 KiB
Go
444 lines
13 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"
|
|
"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)
|
|
}
|
|
}
|
|
worker, err := review.NewWorker(adapter, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
worker.SyncFocus(focus)
|
|
await_result := func() review.Result {
|
|
t.Helper()
|
|
select {
|
|
case result := <-worker.Results():
|
|
if result.Err != nil || result.CaretErr != nil {
|
|
t.Fatalf("document command failed: %+v", result)
|
|
}
|
|
return result
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
return review.Result{}
|
|
}
|
|
}
|
|
move_caret := func(command review.DocumentCaretCommand) review.Result {
|
|
t.Helper()
|
|
if err := worker.MoveDocumentCaret(command); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return await_result()
|
|
}
|
|
previousContent := "Initial mode content"
|
|
for _, mode := range []string{"Basic", "Advanced"} {
|
|
// Use the reader's command path so asynchronous replacement events
|
|
// exercise its bounded recapture and cursor recovery.
|
|
move_caret(review.CaretDocumentStart)
|
|
reached := false
|
|
for step := 0; step < 16; step++ {
|
|
move := move_caret(review.CaretNextLine)
|
|
if strings.TrimSpace(move.DocumentCaretText) == strings.ToUpper(mode) {
|
|
reached = true
|
|
break
|
|
}
|
|
}
|
|
if !reached {
|
|
t.Fatalf("browse caret could not reach %s", mode)
|
|
}
|
|
if err := worker.ActivateDocumentCaret(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result := await_result(); !result.DocumentActivate {
|
|
t.Fatalf("missing activation result: %+v", result)
|
|
}
|
|
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, obsolete := false, false
|
|
for _, node := range document.Nodes {
|
|
ready = ready || node.Snapshot.Name == mode+" mode content"
|
|
obsolete = obsolete || node.Snapshot.Name == previousContent
|
|
}
|
|
if ready {
|
|
if obsolete {
|
|
t.Fatalf("replacement retained %q", previousContent)
|
|
}
|
|
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 := move_caret(review.CaretNextLine)
|
|
if strings.TrimSpace(move.DocumentCaretText) == mode+" mode content" {
|
|
readNewContent = true
|
|
break
|
|
}
|
|
}
|
|
if !readNewContent {
|
|
t.Fatalf("browse caret could not reach %s replacement content", mode)
|
|
}
|
|
previousContent = mode + " mode content"
|
|
}
|
|
}
|
|
|
|
func TestNativeBrowserDisclosureFocus(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
|
t.Skip("requires private browser disclosure 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")
|
|
}
|
|
worker, err := review.NewWorker(adapter, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
worker.SyncFocus(focus)
|
|
testBrowserDisclosureActions(t, ctx, adapter, worker)
|
|
}
|
|
|
|
func testBrowserDisclosureActions(t *testing.T, ctx context.Context, adapter *atspi.Adapter, worker *review.Worker) {
|
|
t.Helper()
|
|
move := func(command review.DocumentCaretCommand) review.Result {
|
|
t.Helper()
|
|
if err := worker.MoveDocumentCaret(command); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case result := <-worker.Results():
|
|
if result.Err != nil || result.CaretErr != nil {
|
|
t.Fatalf("disclosure navigation failed: %+v", result)
|
|
}
|
|
return result
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
return review.Result{}
|
|
}
|
|
}
|
|
waitToggle := func(want string) {
|
|
t.Helper()
|
|
for {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if event.Type == atspi.AccessibleNameEvent && event.Source.Name == want {
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatalf("disclosure did not report %q: %v", want, ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
for _, method := range []string{"actions", "enter"} {
|
|
var label atspi.ObjectReference
|
|
for step := 0; step < 60; step++ {
|
|
command := review.CaretNextLine
|
|
if step == 0 {
|
|
command = review.CaretDocumentStart
|
|
}
|
|
result := move(command)
|
|
if strings.TrimSpace(result.DocumentCaretText) == "Choose category" {
|
|
label = result.DocumentPosition.Object
|
|
break
|
|
}
|
|
}
|
|
if !label.Valid() {
|
|
t.Fatal("disclosure label missing")
|
|
}
|
|
waitToggle("Caret settled:Choose category:0:closed")
|
|
perform := func() {
|
|
t.Helper()
|
|
actions, err := adapter.AccessibleActions(ctx, label.ID())
|
|
if err != nil {
|
|
t.Fatalf("%s list actions: %v", method, err)
|
|
}
|
|
if err := adapter.PerformAccessibleAction(ctx, actions.Target, "clickAncestor"); err != nil {
|
|
current, currentErr := adapter.AccessibleActions(ctx, label.ID())
|
|
t.Fatalf("%s perform action: %v; before=%+v after=%+v (%v)", method, err, actions.Target, current.Target, currentErr)
|
|
}
|
|
}
|
|
if method == "actions" {
|
|
perform()
|
|
} else {
|
|
if err := worker.ActivateDocumentCaret(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case result := <-worker.Results():
|
|
if result.Err != nil {
|
|
t.Fatalf("%s Enter activation: %v", method, result.Err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
waitToggle("Categories opened")
|
|
// A real dropdown's scroll container can acquire browser focus when
|
|
// its first option receives the native caret. Reading within it must
|
|
// not blur that container and dismiss the rest of the options.
|
|
optionAvailable := func(result review.Result) {
|
|
t.Helper()
|
|
prefix := fmt.Sprintf("Caret settled:%s:%d:", result.DocumentPosition.Snapshot.Name, result.DocumentPosition.Offset)
|
|
for {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if event.Type == atspi.AccessibleNameEvent && strings.HasPrefix(event.Source.Name, prefix) {
|
|
if event.Source.Name != prefix+"open" {
|
|
t.Fatalf("%s popup closed after %s", method, result.DocumentCaretCommand)
|
|
}
|
|
goto settled
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatalf("missing %s acknowledgement: %v", prefix, ctx.Err())
|
|
}
|
|
}
|
|
settled:
|
|
if _, err := adapter.AccessibleActions(ctx, result.DocumentPosition.Object.ID()); err != nil {
|
|
t.Fatalf("%s popup option disappeared after %s: %v", method, result.DocumentCaretCommand, err)
|
|
}
|
|
}
|
|
first := move(review.CaretNextLine)
|
|
if strings.TrimSpace(first.DocumentCaretText) != "First category" {
|
|
t.Fatalf("%s first disclosure option: %q", method, first.DocumentCaretText)
|
|
}
|
|
optionAvailable(first)
|
|
character := move(review.CaretNextCharacter)
|
|
if character.DocumentCaretText != "i" {
|
|
t.Fatalf("%s disclosure character: got %q, want i", method, character.DocumentCaretText)
|
|
}
|
|
optionAvailable(character)
|
|
optionAvailable(move(review.CaretLineStart))
|
|
for _, want := range []string{"Second category"} {
|
|
result := move(review.CaretNextLine)
|
|
if strings.TrimSpace(result.DocumentCaretText) != want {
|
|
t.Fatalf("%s disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want)
|
|
}
|
|
optionAvailable(result)
|
|
}
|
|
// Both options must remain available when browsing backwards too.
|
|
for _, want := range []string{"First category"} {
|
|
result := move(review.CaretPreviousLine)
|
|
if strings.TrimSpace(result.DocumentCaretText) != want {
|
|
t.Fatalf("%s reverse disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want)
|
|
}
|
|
optionAvailable(result)
|
|
}
|
|
perform()
|
|
waitToggle("Categories closed")
|
|
}
|
|
// Text caret placement must still reveal offscreen text, and the
|
|
// explicit component scroll remains necessary for a non-text image.
|
|
end := move(review.CaretDocumentEnd)
|
|
if end.DocumentPosition.Snapshot.Name != "Distant image" {
|
|
t.Fatalf("document end = %q, want distant image", end.DocumentPosition.Snapshot.Name)
|
|
}
|
|
waitToggle("Viewport:distant-image")
|
|
previous := move(review.CaretPreviousLine)
|
|
if strings.TrimSpace(previous.DocumentCaretText) != "Distant text" {
|
|
t.Fatalf("offscreen previous line = %q", previous.DocumentCaretText)
|
|
}
|
|
waitToggle("Viewport:distant-text")
|
|
}
|