Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeLinkedHeadings(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_LINKED_HEADINGS") != "1" {
t.Skip("requires private linked heading browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "shift+Tab").CombinedOutput(); err != nil {
t.Fatalf("fixture focus: %s, %v", output, err)
}
var focus atspi.ObjectID
var bounds atspi.Rectangle
for !focus.Valid() && ctx.Err() == nil {
tree, err := adapter.CaptureDesktop(ctx)
if err == nil {
focus = linkedHeadingFixtureFocus(tree)
if focus.Valid() {
ref, _ := tree.Reference(focus)
geometry, _ := tree.Geometry(ref)
if geometry.BoundsKnown {
bounds = geometry.Bounds
}
}
}
if !focus.Valid() {
time.Sleep(100 * time.Millisecond)
}
}
if !focus.Valid() {
t.Fatal("linked heading fixture input absent from accessible desktop")
}
if !bounds.Valid() {
t.Fatal("linked heading fixture input has no screen bounds")
}
keys := []string{"key", "--clearmodifiers", "ctrl+l"}
if output, err := exec.CommandContext(ctx, "xdotool", keys...).CombinedOutput(); err != nil {
t.Fatalf("focus browser chrome: %s, %v", output, err)
}
pointX, pointY := bounds.X+bounds.Width/2, bounds.Y+bounds.Height/2
if output, err := exec.CommandContext(ctx, "xdotool", "mousemove", "--sync", fmt.Sprint(pointX), fmt.Sprint(pointY), "click", "1").CombinedOutput(); err != nil {
t.Fatalf("focus fixture input: %s, %v", output, err)
}
focused := false
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for !focused {
select {
case event := <-adapter.Events():
focused = event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == focus
case <-deadline.C:
t.Fatal("fixture input did not emit native focus")
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
for _, tc := range []struct {
level int
name, title string
moves int
}{
{2, "Inside heading link", "Cthulhu Linked Heading Inside", 1},
{3, "Outside heading link", "Cthulhu Linked Heading Outside", 1},
{3, "#1 Username Example date", "Cthulhu Linked Heading Post", 2},
} {
tree, err := adapter.CaptureDocument(ctx, focus)
if err != nil {
t.Fatal(err)
}
cursor, err := review.NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
t.Fatal(err)
}
move, err := cursor.Move(ctx, review.DocumentHeading, tc.level, false)
for step := 1; step < tc.moves && err == nil; step++ {
move, err = cursor.Move(ctx, review.DocumentHeading, tc.level, false)
}
if err != nil || !move.Moved || move.Position.Snapshot.Name != tc.name || move.Position.LinkedHeading == nil {
t.Fatalf("heading level %d: %+v, %v", tc.level, move, err)
}
target, err := cursor.ActivationTarget()
if err != nil {
t.Fatal(err)
}
link, err := tree.Resolve(target)
if err != nil || link.Role != "link" {
t.Fatalf("activation target = %+v, %v", link, err)
}
if err := adapter.ActivateDocumentObject(ctx, target, focus); err != nil {
t.Fatalf("activate %s: %v", tc.name, err)
}
for {
output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", tc.title).Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
break
}
if ctx.Err() != nil {
t.Fatalf("activation did not open %s: %s", tc.title, output)
}
time.Sleep(50 * time.Millisecond)
}
}
tree, err := adapter.CaptureDocument(ctx, focus)
if err != nil {
t.Fatal(err)
}
cursor, err := review.NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
t.Fatal(err)
}
move, err := cursor.Move(ctx, review.DocumentHeading, 5, false)
if err != nil || !move.Moved || move.Position.LinkedHeading != nil {
t.Fatalf("plain-text heading with multiple links: %+v, %v", move, err)
}
if target, err := cursor.ActivationTarget(); !errors.Is(err, review.ErrNoCurrent) {
t.Fatalf("ambiguous activation target = %v, %v", target, err)
}
}
func linkedHeadingFixtureFocus(tree *atspi.AccessibleTree) atspi.ObjectID {
root, err := tree.Root()
if err != nil {
return atspi.ObjectID{}
}
pending := []atspi.ObjectReference{root}
for len(pending) != 0 {
current := pending[len(pending)-1]
pending = pending[:len(pending)-1]
snapshot, err := tree.Resolve(current)
if err != nil {
continue
}
if snapshot.Name == "Probe start" && snapshot.Role == "entry" {
return current.ID()
}
for child, ok, err := tree.FirstChild(current); err == nil && ok; child, ok, err = tree.NextSibling(child) {
pending = append(pending, child)
}
}
return atspi.ObjectID{}
}