// 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") } for _, name := range []string{"Firefox baseline heading", "Firefox baseline link"} { object := findNamedReference(t, page, name) snapshot, err := page.Resolve(object) if err != nil || snapshot.CharacterCount == 0 { t.Fatalf("browse text metrics missing for %q: count=%d err=%v", name, snapshot.CharacterCount, err) } } inline := findNamedReference(t, page, "Inline message link") parent, ok, err := page.Parent(inline) if err != nil || !ok { t.Fatalf("inline parent: %v", err) } mixed, err := page.Resolve(parent) if err != nil || len(mixed.TextChildren) != 1 || mixed.TextChildren[0].Offset != 13 || mixed.TextChildren[0].Child != inline.ID() { t.Fatalf("mixed native text offsets: children=%v err=%v", mixed.TextChildren, err) } mixedLine, err := adapter.ReadDocumentCaret(ctx, parent, 0, DocumentCaretBrailleLine) if err != nil || mixedLine.RawText != "Mixed before \ufffc after." { t.Fatalf("mixed native line=%q err=%v", mixedLine.RawText, err) } 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") }