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

+176
View File
@@ -0,0 +1,176 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestNativeFirefoxBaseline(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_FIREFOX") != "1" {
t.Skip("requires private Firefox session")
}
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stop, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := adapter.Stop(stop); err != nil {
t.Error(err)
}
}()
window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Firefox Baseline").Output()
if err != nil || len(strings.Fields(string(window))) == 0 {
t.Fatalf("Firefox window: %s: %v", window, err)
}
if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Fields(string(window))[0]).CombinedOutput(); err != nil {
t.Fatalf("focus Firefox: %s: %v", out, err)
}
var tree *AccessibleTree
for ctx.Err() == nil {
tree, err = adapter.CaptureDesktop(ctx)
if err == nil && findNamedReference(t, tree, "Firefox baseline heading").Valid() {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
t.Fatalf("capture Firefox desktop: %v", err)
}
heading := findNamedReference(t, tree, "Firefox baseline heading")
if !heading.Valid() {
t.Fatal("Firefox page heading absent from AT-SPI tree")
}
snapshot, err := tree.Resolve(heading)
if err != nil {
t.Fatal(err)
}
if snapshot.HeadingLevel != 1 {
t.Fatalf("Firefox heading semantics: role=%q level=%d", snapshot.Role, snapshot.HeadingLevel)
}
owner, err := tree.DocumentContext(heading)
if err != nil {
t.Fatalf("Firefox heading has no web document: %v", err)
}
document, err := tree.Document(ctx, heading)
if err != nil {
t.Fatalf("capture Firefox document: %v", err)
}
paragraphFound := false
for _, node := range document.Nodes {
paragraphFound = paragraphFound || node.Snapshot.Role == "paragraph"
}
if !paragraphFound {
t.Fatal("Firefox paragraph absent from document")
}
for _, name := range []string{"Firefox baseline link", "Firefox baseline entry"} {
if !findNamedReference(t, tree, name).Valid() {
t.Errorf("Firefox page object %q absent", name)
}
}
documentRoot, err := tree.Resolve(owner.Document)
if err != nil || documentRoot.Role != "document web" {
t.Fatalf("Firefox document root=%+v err=%v", documentRoot, err)
}
t.Logf("Firefox document role=%q nodes=%d heading=%q", documentRoot.Role, len(document.Nodes), snapshot.Name)
if out, err := exec.CommandContext(ctx, "xdotool", "key", "ctrl+l").CombinedOutput(); err != nil {
t.Fatalf("focus address bar: %s: %v", out, err)
}
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
chromeFocus:
for {
select {
case event := <-adapter.Events():
if event.Source.ApplicationProcess != "firefox" || !event.Source.Focused || event.Source.Role != "combo box" {
continue
}
if event.Type != FocusEvent && event.Type != FocusedStateEvent {
continue
}
if _, err := adapter.CaptureDocumentContext(ctx, event.Source.ID()); err != ErrNotInDocument {
t.Fatalf("Firefox chrome %q belongs to page document: %v", event.Source.Name, err)
}
if !event.Source.Autocomplete || !event.Source.HasPopup || !event.Source.Editable {
t.Fatalf("Firefox address bar semantics: role=%q editable=%t popup=%t autocomplete=%t", event.Source.Role, event.Source.Editable, event.Source.HasPopup, event.Source.Autocomplete)
}
t.Logf("Firefox chrome focus role=%q name=%q process=%q editable=%t popup=%t autocomplete=%t", event.Source.Role, event.Source.Name, event.Source.ApplicationProcess, event.Source.Editable, event.Source.HasPopup, event.Source.Autocomplete)
break chromeFocus
case <-deadline.C:
t.Fatal("Firefox address-bar focus event missing")
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
// Return through Firefox's real keyboard order; a tree-only capture cannot
// establish whether the reader can recapture the active page after chrome.
for attempt := 0; attempt < 25; attempt++ {
if out, err := exec.CommandContext(ctx, "xdotool", "key", "Tab").CombinedOutput(); err != nil {
t.Fatalf("tab from Firefox chrome: %s: %v", out, err)
}
wait := time.NewTimer(300 * time.Millisecond)
events:
for {
select {
case event := <-adapter.Events():
if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.ApplicationProcess != "firefox" || !event.Source.Focused {
continue
}
if event.Source.Name != "Firefox baseline link" && event.Source.Name != "Firefox baseline entry" {
continue
}
wait.Stop()
owner, err := adapter.CaptureDocumentContext(ctx, event.Source.ID())
if err != nil || !owner.Document.Valid() {
t.Fatalf("Firefox page focus context: %v", err)
}
page, err := adapter.CaptureDocument(ctx, event.Source.ID())
if err != nil {
t.Fatalf("Firefox focused document capture: %v", err)
}
ref := findNamedReference(t, page, "Firefox baseline heading")
if !ref.Valid() {
t.Fatal("Firefox heading missing from focused document capture")
}
var paragraph ObjectReference
captured, err := page.Document(ctx, ref)
if err != nil {
t.Fatal(err)
}
for _, node := range captured.Nodes {
if node.Snapshot.Role == "paragraph" {
paragraph = node.Reference
break
}
}
if !paragraph.Valid() {
t.Fatal("Firefox paragraph missing from focused document capture")
}
line, err := adapter.ReadDocumentCaret(ctx, paragraph, 0, DocumentCaretBrailleLine)
if err != nil || strings.TrimSpace(line.Text) != "Firefox baseline paragraph." {
t.Fatalf("Firefox paragraph line=%q err=%v", line.Text, err)
}
t.Logf("Firefox page focus role=%q name=%q", event.Source.Role, event.Source.Name)
return
case <-wait.C:
break events
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
wait.Stop()
}
t.Fatal("Firefox page focus unreachable from address bar with Tab")
}