Thunderbird HTML messages expose readable text on containers around embedded objects. The browse cursor previously considered only text leaves, and native document capture omitted heading/link character counts. This skipped message content, headings and link text. A caret event following Tab could also replace a complete link announcement with its first character. Capture owned hypertext child identities and native placeholder offsets on the GLib thread, releasing native interfaces before returning. Require every child to occupy one embedded-object character so providers exposing duplicate text through static descendants retain their ordinary traversal. Traverse native text ranges before and after embedded children without changing their offset domains. Preserve character, word, line and document-boundary navigation; normalize native caret and Braille routing positions at adjacent embedded links. Keep the Braille cursor on the correct fragment when a parent contributes text more than once to an assembled line. Ignore same-input link caret echoes after focus while retaining subsequent arrow and selection feedback. Add regression tests, extend the private Firefox fixture, and record behavior and remaining acceptance in the parity ledger. Validation: - Full Go test suite with count=1; focused app/atspi/review race checks. - go vet ./...; reader and brlapi helper builds. - Private Firefox baseline, mixed-parent offsets and tab navigation. - Private Chromium fragments, named links, document navigation and selection. - Parity ledger validation and whitespace checks. The existing Firefox tab test intermittently timed out on both an untouched HEAD copy and the changed tree; the final run passed. The unchanged TestRunReachesSoundStopAfterEarlierServiceStopError also failed once during startup cancellation and passed in subsequent full-suite and race runs. Both installed binaries were verified and the reader reloaded in its original desktop session. The maintainer confirmed readable message text and later working links after an earlier intermittent omission. That omission remains untraced; repeat live acceptance and physical Braille verification remain open.
197 lines
7.1 KiB
Go
197 lines
7.1 KiB
Go
// 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")
|
|
}
|